diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c6c2b3b51e..1d1a6eec16 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,7 @@ "name": "Immich - Backend, Frontend and ML", "service": "immich-server", "runServices": [ + "immich-init", "immich-server", "redis", "database", @@ -31,29 +32,8 @@ "tasks": { "version": "2.0.0", "tasks": [ - { - "label": "Fix Permissions, Install Dependencies", - "type": "shell", - "command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0", - "isBackground": true, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": true, - "clear": false, - "group": "Devcontainer tasks", - "close": true - }, - "runOptions": { - "runOn": "default" - }, - "problemMatcher": [] - }, { "label": "Immich API Server (Nest)", - "dependsOn": ["Fix Permissions, Install Dependencies"], "type": "shell", "command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0", "isBackground": true, @@ -74,7 +54,6 @@ }, { "label": "Immich Web Server (Vite)", - "dependsOn": ["Fix Permissions, Install Dependencies"], "type": "shell", "command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0", "isBackground": true, @@ -130,8 +109,8 @@ } }, "overrideCommand": true, - "workspaceFolder": "/workspaces/immich", - "remoteUser": "node", + "workspaceFolder": "/usr/src/app", + "remoteUser": "root", "userEnvProbe": "loginInteractiveShell", "remoteEnv": { // The location where your uploaded files are stored diff --git a/.devcontainer/mobile/container-compose-overrides.yml b/.devcontainer/mobile/container-compose-overrides.yml index 99e41cbece..3d9e1b00b6 100644 --- a/.devcontainer/mobile/container-compose-overrides.yml +++ b/.devcontainer/mobile/container-compose-overrides.yml @@ -1,23 +1,17 @@ services: + immich-app-base: + image: busybox immich-server: + extends: + service: immich-app-base + profiles: !reset [] + image: immich-server-dev:latest build: target: dev-container-mobile environment: - IMMICH_SERVER_URL=http://127.0.0.1:2283/ - volumes: !override # bind mount host to /workspaces/immich - - ..:/workspaces/immich + volumes: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage - /etc/localtime:/etc/localtime:ro immich-web: env_file: !reset [] diff --git a/.devcontainer/mobile/devcontainer.json b/.devcontainer/mobile/devcontainer.json index 140a2ecac3..0be9b72969 100644 --- a/.devcontainer/mobile/devcontainer.json +++ b/.devcontainer/mobile/devcontainer.json @@ -2,6 +2,7 @@ "name": "Immich - Mobile", "service": "immich-server", "runServices": [ + "immich-init", "immich-server", "redis", "database", @@ -35,7 +36,7 @@ }, "forwardPorts": [], "overrideCommand": true, - "workspaceFolder": "/workspaces/immich", + "workspaceFolder": "/usr/src/app", "remoteUser": "node", "userEnvProbe": "loginInteractiveShell", "remoteEnv": { diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh index 3aa72379c3..fa3e60f211 100755 --- a/.devcontainer/server/container-common.sh +++ b/.devcontainer/server/container-common.sh @@ -2,11 +2,6 @@ export IMMICH_PORT="${DEV_SERVER_PORT:-2283}" export DEV_PORT="${DEV_PORT:-3000}" -# search for immich directory inside workspace. -# /workspaces/immich is the bind mount, but other directories can be mounted if runing -# Devcontainer: Clone [repository|pull request] in container volumne -WORKSPACES_DIR="/workspaces" -IMMICH_DIR="$WORKSPACES_DIR/immich" IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log" log() { @@ -30,52 +25,8 @@ run_cmd() { return "${PIPESTATUS[0]}" } -# Find directories excluding /workspaces/immich -mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*") - -if [ ${#other_dirs[@]} -gt 1 ]; then - log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR." - exit 1 -elif [ ${#other_dirs[@]} -eq 1 ]; then - export IMMICH_WORKSPACE="${other_dirs[0]}" -else - export IMMICH_WORKSPACE="$IMMICH_DIR" -fi +export IMMICH_WORKSPACE="/usr/src/app" log "Found immich workspace in $IMMICH_WORKSPACE" log "" -fix_permissions() { - - log "Fixing permissions for ${IMMICH_WORKSPACE}" - - # Change ownership for directories that exist - for dir in "${IMMICH_WORKSPACE}/.vscode" \ - "${IMMICH_WORKSPACE}/server/upload" \ - "${IMMICH_WORKSPACE}/.pnpm-store" \ - "${IMMICH_WORKSPACE}/.github/node_modules" \ - "${IMMICH_WORKSPACE}/cli/node_modules" \ - "${IMMICH_WORKSPACE}/e2e/node_modules" \ - "${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \ - "${IMMICH_WORKSPACE}/server/node_modules" \ - "${IMMICH_WORKSPACE}/server/dist" \ - "${IMMICH_WORKSPACE}/web/node_modules" \ - "${IMMICH_WORKSPACE}/web/dist"; do - if [ -d "$dir" ]; then - run_cmd sudo chown node -R "$dir" - fi - done - - log "" -} - -install_dependencies() { - - log "Installing dependencies" - ( - cd "${IMMICH_WORKSPACE}" || exit 1 - export CI=1 FROZEN=1 OFFLINE=1 - run_cmd make setup-web-dev setup-server-dev - ) - log "" -} diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index cc2b0c907b..5c312efd07 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -1,26 +1,21 @@ services: + immich-app-base: + image: busybox immich-server: + extends: + service: immich-app-base + profiles: !reset [] + image: immich-server-dev:latest build: target: dev-container-server env_file: !reset [] hostname: immich-dev environment: - IMMICH_SERVER_URL=http://127.0.0.1:2283/ - volumes: !override - - ..:/workspaces/immich + volumes: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_server:/buildcache/pnpm-store - ../plugins:/build/corePlugin immich-web: env_file: !reset [] diff --git a/.devcontainer/server/container-start.sh b/.devcontainer/server/container-start.sh deleted file mode 100755 index 0edd38172e..0000000000 --- a/.devcontainer/server/container-start.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# shellcheck source=common.sh -# shellcheck disable=SC1091 -source /immich-devcontainer/container-common.sh - -log "Setting up Immich dev container..." -fix_permissions - -log "Setup complete, please wait while backend and frontend services automatically start" -log -log "If necessary, the services may be manually started using" -log -log "$ /immich-devcontainer/container-start-backend.sh" -log "$ /immich-devcontainer/container-start-frontend.sh" -log -log "From different terminal windows, as these scripts automatically restart the server" -log "on error, and will continuously run in a loop" diff --git a/.github/.nvmrc b/.github/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/.github/.nvmrc +++ b/.github/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0bd3b30814..2d1fdafa30 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -26,6 +26,7 @@ The `/api/something` endpoint is now `/api/something-else` ## Checklist: +- [ ] I have carefully read CONTRIBUTING.md - [ ] I have performed a self-review of my own code - [ ] I have made corresponding changes to the documentation if applicable - [ ] I have no unrelated changes in the PR. diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b8ce6387af..44645c1e1b 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -51,14 +51,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -79,12 +79,12 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false @@ -96,14 +96,14 @@ jobs: working-directory: ./mobile run: printf "%s" $KEY_JKS | base64 -d > android/key.jks - - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'zulu' java-version: '17' - name: Restore Gradle Cache id: cache-gradle-restore - uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | ~/.gradle/caches @@ -160,7 +160,7 @@ jobs: - name: Save Gradle Cache id: cache-gradle-save - uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 if: github.ref == 'refs/heads/main' with: path: | @@ -185,7 +185,7 @@ jobs: run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml index 55f91e7989..3de4676622 100644 --- a/.github/workflows/cache-cleanup.yml +++ b/.github/workflows/cache-cleanup.yml @@ -19,13 +19,13 @@ jobs: actions: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check out code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml new file mode 100644 index 0000000000..eee2c9f488 --- /dev/null +++ b/.github/workflows/check-openapi.yml @@ -0,0 +1,32 @@ +name: Check OpenAPI +on: + workflow_dispatch: + pull_request: + paths: + - 'open-api/**' + - '.github/workflows/check-openapi.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + check-openapi: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Check for breaking API changes + # sha is pinning to a commit instead of a tag since the action does not tag versions + uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4 + with: + base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json + revision: open-api/immich-openapi-specs.json + fail-on: ERR diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 3591539b68..a2c763a0f6 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -31,12 +31,12 @@ jobs: working-directory: ./cli steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -45,7 +45,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './cli/.nvmrc' registry-url: 'https://registry.npmjs.org' @@ -71,13 +71,13 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -89,7 +89,7 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io @@ -115,7 +115,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index 09e9dbb338..1b18c0c5e1 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -35,7 +35,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6 + image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/close-llm-pr.yml b/.github/workflows/close-llm-pr.yml new file mode 100644 index 0000000000..511d5c7f55 --- /dev/null +++ b/.github/workflows/close-llm-pr.yml @@ -0,0 +1,38 @@ +name: Close LLM-generated PRs + +on: + pull_request_target: + types: [labeled] + +permissions: {} + +jobs: + comment_and_close: + runs-on: ubuntu-latest + if: ${{ github.event.label.name == 'llm-generated' }} + permissions: + pull-requests: write + steps: + - name: Comment and close + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \ + -f query=' + mutation CommentAndClosePR($prId: ID!, $body: String!) { + addComment(input: { + subjectId: $prId, + body: $body + }) { + __typename + } + + closePullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 71b5968960..67e0b4b972 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -44,20 +44,20 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 41daebd3a7..1636076491 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -23,14 +23,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -60,7 +60,7 @@ jobs: suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -90,7 +90,7 @@ jobs: suffix: [''] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -132,7 +132,7 @@ jobs: suffixes: '-rocm' platforms: linux/amd64 runner-mapping: '{"linux/amd64": "pokedex-giant"}' - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 permissions: contents: read actions: read @@ -155,7 +155,7 @@ jobs: name: Build and Push Server needs: pre-job if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 permissions: contents: read actions: read diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 91916e4ed2..28828f22c6 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -21,14 +21,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -54,13 +54,13 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -70,7 +70,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './docs/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 8c0bf76f30..babda72c33 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -20,7 +20,7 @@ jobs: artifact: ${{ steps.get-artifact.outputs.result }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} @@ -119,19 +119,19 @@ jobs: if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 + uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2 - name: Load parameters id: parameters @@ -192,16 +192,13 @@ jobs: ' >> $GITHUB_OUTPUT - name: Publish to Cloudflare Pages - # TODO: Action is deprecated - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - projectName: ${{ steps.docs-output.outputs.projectName }} - workingDirectory: 'docs' - directory: 'build' - branch: ${{ steps.parameters.outputs.name }} - wranglerVersion: '3' + working-directory: docs + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PROJECT_NAME: ${{ steps.docs-output.outputs.projectName }} + BRANCH_NAME: ${{ steps.parameters.outputs.name }} + run: mise run //docs:deploy - name: Deploy Docs Release Domain if: ${{ steps.parameters.outputs.event == 'release' }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index a7d068cb43..05842889cc 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -17,19 +17,19 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 + uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2 - name: Destroy Docs Subdomain env: diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 11a9ef06e4..1daa279cd2 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -22,7 +22,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: 'Checkout' - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.ref }} token: ${{ steps.generate-token.outputs.token }} @@ -32,14 +32,14 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - name: Fix formatting - run: pnpm --recursive install && pnpm run --recursive --parallel fix:format + run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix - name: Commit and push uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index 0544de3dad..e04b32d74f 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 263426e548..24f3f8faf1 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 373fbaf6c1..a1d31a61ea 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -56,20 +56,20 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true ref: main - name: Install uv - uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -130,7 +130,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml index 8760b67fc0..dc6f0eff0a 100644 --- a/.github/workflows/preview-label.yaml +++ b/.github/workflows/preview-label.yaml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} @@ -32,7 +32,7 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 3ee96c45b7..93e18a4fcc 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,20 +23,20 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true ref: main - name: Install uv - uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -159,7 +159,7 @@ jobs: - name: Create PR id: create-pr - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ steps.generate-token.outputs.token }} commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30783f5e9b..30e9c1c7ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false @@ -88,6 +88,7 @@ jobs: draft: true files: | docker/docker-compose.yml + docker/docker-compose.rootless.yml docker/example.env docker/hwaccel.ml.yml docker/hwaccel.transcoding.yml diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index bd2c292ad5..1bcdec4747 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -19,12 +19,12 @@ jobs: working-directory: ./open-api/typescript-sdk steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -33,7 +33,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './open-api/typescript-sdk/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index c0d53388c6..d100dd281f 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -20,14 +20,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -49,13 +49,13 @@ jobs: working-directory: ./mobile steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -69,6 +69,14 @@ jobs: - name: Install dependencies run: dart pub get + - name: Install dependencies for UI package + run: dart pub get + working-directory: ./mobile/packages/ui + + - name: Install dependencies for UI Showcase + run: dart pub get + working-directory: ./mobile/packages/ui/showcase + - name: Install DCM uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93efccf2e1..1cad2b0023 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,14 +17,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -63,13 +63,13 @@ jobs: working-directory: ./server steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -77,7 +77,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -108,20 +108,20 @@ jobs: working-directory: ./cli steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -155,20 +155,20 @@ jobs: working-directory: ./cli steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -197,20 +197,20 @@ jobs: working-directory: ./web steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -241,20 +241,20 @@ jobs: working-directory: ./web steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -279,20 +279,20 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -327,20 +327,20 @@ jobs: working-directory: ./e2e steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -373,13 +373,13 @@ jobs: working-directory: ./server steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false submodules: 'recursive' @@ -387,7 +387,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -412,13 +412,13 @@ jobs: runner: [ubuntu-latest, ubuntu-24.04-arm] steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false submodules: 'recursive' @@ -426,7 +426,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -446,12 +446,29 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile if: ${{ !cancelled() }} - - name: Docker build - run: docker compose build + - name: Start Docker Compose + run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 if: ${{ !cancelled() }} - name: Run e2e tests (api & cli) + env: + VITEST_DISABLE_DOCKER_SETUP: true run: pnpm test if: ${{ !cancelled() }} + - name: Run e2e tests (maintenance) + env: + VITEST_DISABLE_DOCKER_SETUP: true + run: pnpm test:maintenance + if: ${{ !cancelled() }} + - name: Capture Docker logs + if: always() + run: docker compose logs --no-color > docker-compose-logs.txt + working-directory: ./e2e + - name: Archive Docker logs + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + if: always() + with: + name: e2e-server-docker-logs-${{ matrix.runner }} + path: e2e/docker-compose-logs.txt e2e-tests-web: name: End-to-End Tests (Web) needs: pre-job @@ -467,13 +484,13 @@ jobs: runner: [ubuntu-latest, ubuntu-24.04-arm] steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false submodules: 'recursive' @@ -481,7 +498,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -494,16 +511,15 @@ jobs: run: pnpm install --frozen-lockfile if: ${{ !cancelled() }} - name: Install Playwright Browsers - run: npx playwright install chromium --only-shell + run: pnpm exec playwright install chromium --only-shell if: ${{ !cancelled() }} - name: Docker build run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 if: ${{ !cancelled() }} - name: Run e2e tests (web) env: - CI: true PLAYWRIGHT_DISABLE_WEBSERVER: true - run: npx playwright test --project=web + run: pnpm test:web if: ${{ !cancelled() }} - name: Archive e2e test (web) results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 @@ -513,9 +529,8 @@ jobs: path: e2e/playwright-report/ - name: Run ui tests (web) env: - CI: true PLAYWRIGHT_DISABLE_WEBSERVER: true - run: npx playwright test --project=ui + run: pnpm test:web:ui if: ${{ !cancelled() }} - name: Archive ui test (web) results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 @@ -525,9 +540,8 @@ jobs: path: e2e/playwright-report/ - name: Run maintenance tests env: - CI: true PLAYWRIGHT_DISABLE_WEBSERVER: true - run: npx playwright test --project=maintenance + run: pnpm test:web:maintenance if: ${{ !cancelled() }} - name: Archive maintenance tests (web) results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 @@ -543,7 +557,7 @@ jobs: uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: always() with: - name: docker-compose-logs-${{ matrix.runner }} + name: e2e-web-docker-logs-${{ matrix.runner }} path: e2e/docker-compose-logs.txt success-check-e2e: name: End-to-End Tests Success @@ -564,12 +578,12 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -596,17 +610,17 @@ jobs: working-directory: ./machine-learning steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 with: python-version: 3.11 - name: Install dependencies @@ -636,20 +650,20 @@ jobs: working-directory: ./.github steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './.github/.nvmrc' cache: 'pnpm' @@ -666,12 +680,12 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -687,20 +701,20 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -749,20 +763,20 @@ jobs: working-directory: ./server steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml index cb11a11be4..6e997ad76a 100644 --- a/.github/workflows/weblate-lock.yml +++ b/.github/workflows/weblate-lock.yml @@ -24,14 +24,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -47,7 +47,7 @@ jobs: if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index 0e76dabe66..6dbed0bb6c 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -4,12 +4,18 @@ module.exports = { if (!pkg.name) { return pkg; } + // make exiftool-vendored.pl a regular dependency since Docker prod + // images build with --no-optional to reduce image size if (pkg.name === "exiftool-vendored") { - if (pkg.optionalDependencies["exiftool-vendored.pl"]) { - // make exiftool-vendored.pl a regular dependency - pkg.dependencies["exiftool-vendored.pl"] = - pkg.optionalDependencies["exiftool-vendored.pl"]; - delete pkg.optionalDependencies["exiftool-vendored.pl"]; + const binaryPackage = + process.platform === "win32" + ? "exiftool-vendored.exe" + : "exiftool-vendored.pl"; + + if (pkg.optionalDependencies[binaryPackage]) { + pkg.dependencies[binaryPackage] = + pkg.optionalDependencies[binaryPackage]; + delete pkg.optionalDependencies[binaryPackage]; } } return pkg; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 109708cc6e..1695403cb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ If you are looking for something to work on, there are discussions and issues wi ## Use of generative AI -We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template. +We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request. ## Feature freezes diff --git a/Makefile b/Makefile index 2fc1c5d801..4d76913d8f 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ attach-server: docker exec -it docker_immich-server_1 sh renovate: - LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset + LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset # Directories that need to be created for volumes or build output VOLUME_DIRS = \ diff --git a/cli/.nvmrc b/cli/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/cli/package.json b/cli/package.json index 28bee420aa..d553ffb299 100644 --- a/cli/package.json +++ b/cli/package.json @@ -14,13 +14,13 @@ ], "devDependencies": { "@eslint/js": "^9.8.0", - "@immich/sdk": "file:../open-api/typescript-sdk", + "@immich/sdk": "workspace:*", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", @@ -45,8 +45,8 @@ "build": "vite build", "build:dev": "vite build --sourcemap true", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix", - "prepack": "npm run build", + "lint:fix": "pnpm run lint --fix", + "prepack": "pnpm run build", "test": "vitest", "test:cov": "vitest --coverage", "format": "prettier --check .", @@ -69,6 +69,6 @@ "micromatch": "^4.0.8" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index 7dce135985..ea57eeb74b 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -7,7 +7,15 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest'; import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; import createFetchMock from 'vitest-fetch-mock'; -import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset'; +import { + checkForDuplicates, + deleteFiles, + findSidecar, + getAlbumName, + startWatch, + uploadFiles, + UploadOptionsDto, +} from 'src/commands/asset'; vi.mock('@immich/sdk'); @@ -309,3 +317,85 @@ describe('startWatch', () => { await fs.promises.rm(testFolder, { recursive: true, force: true }); }); }); + +describe('findSidecar', () => { + let testDir: string; + let testFilePath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-')); + testFilePath = path.join(testDir, 'test.jpg'); + fs.writeFileSync(testFilePath, 'test'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should find sidecar file with photo.xmp naming convention', () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + const result = findSidecar(testFilePath); + expect(result).toBe(sidecarPath); + }); + + it('should find sidecar file with photo.ext.xmp naming convention', () => { + const sidecarPath = path.join(testDir, 'test.jpg.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + const result = findSidecar(testFilePath); + expect(result).toBe(sidecarPath); + }); + + it('should prefer photo.ext.xmp over photo.xmp when both exist', () => { + const sidecarPath1 = path.join(testDir, 'test.xmp'); + const sidecarPath2 = path.join(testDir, 'test.jpg.xmp'); + fs.writeFileSync(sidecarPath1, 'xmp data 1'); + fs.writeFileSync(sidecarPath2, 'xmp data 2'); + + const result = findSidecar(testFilePath); + // Should return the first one found (photo.xmp) based on the order in the code + expect(result).toBe(sidecarPath1); + }); + + it('should return undefined when no sidecar file exists', () => { + const result = findSidecar(testFilePath); + expect(result).toBeUndefined(); + }); +}); + +describe('deleteFiles', () => { + let testDir: string; + let testFilePath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-')); + testFilePath = path.join(testDir, 'test.jpg'); + fs.writeFileSync(testFilePath, 'test'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should delete asset and sidecar file when main file is deleted', async () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 }); + + expect(fs.existsSync(testFilePath)).toBe(false); + expect(fs.existsSync(sidecarPath)).toBe(false); + }); + + it('should not delete sidecar file when delete option is false', async () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 }); + + expect(fs.existsSync(testFilePath)).toBe(true); + expect(fs.existsSync(sidecarPath)).toBe(true); + }); +}); diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index ff7b609eef..7d4b09b69d 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -4,6 +4,7 @@ import { AssetBulkUploadCheckResult, AssetMediaResponseDto, AssetMediaStatus, + Permission, addAssetsToAlbum, checkBulkUpload, createAlbum, @@ -16,17 +17,15 @@ import { Matcher, watch as watchFs } from 'chokidar'; import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { chunk } from 'lodash-es'; import micromatch from 'micromatch'; -import { Stats, createReadStream } from 'node:fs'; +import { Stats, createReadStream, existsSync } from 'node:fs'; import { stat, unlink } from 'node:fs/promises'; import path, { basename } from 'node:path'; import { Queue } from 'src/queue'; -import { BaseOptions, Batcher, authenticate, crawl, sha1 } from 'src/utils'; +import { BaseOptions, Batcher, authenticate, crawl, requirePermissions, s, sha1 } from 'src/utils'; const UPLOAD_WATCH_BATCH_SIZE = 100; const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000; -const s = (count: number) => (count === 1 ? '' : 's'); - // TODO figure out why `id` is missing type AssetBulkUploadCheckResults = Array; type Asset = { id: string; filepath: string }; @@ -136,6 +135,7 @@ export const startWatch = async ( export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => { await authenticate(baseOptions); + await requirePermissions([Permission.AssetUpload]); const scanFiles = await scan(paths, options); @@ -180,18 +180,49 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas } let multiBar: MultiBar | undefined; + let totalSize = 0; + const statsMap = new Map(); + + // Calculate total size first + for (const filepath of files) { + const stats = await stat(filepath); + statsMap.set(filepath, stats); + totalSize += stats.size; + } if (progress) { multiBar = new MultiBar( - { format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' }, + { + format: '{message} | {bar} | {percentage}% | ETA: {eta_formatted} | {value}/{total}', + formatValue: (v: number, options, type) => { + // Don't format percentage + if (type === 'percentage') { + return v.toString(); + } + return byteSize(v).toString(); + }, + etaBuffer: 100, // Increase samples for ETA calculation + }, Presets.shades_classic, ); + + // Ensure we restore cursor on interrupt + process.on('SIGINT', () => { + if (multiBar) { + multiBar.stop(); + } + process.exit(0); + }); } else { - console.log(`Received ${files.length} files, hashing...`); + console.log(`Received ${files.length} files (${byteSize(totalSize)}), hashing...`); } - const hashProgressBar = multiBar?.create(files.length, 0, { message: 'Hashing files ' }); - const checkProgressBar = multiBar?.create(files.length, 0, { message: 'Checking for duplicates' }); + const hashProgressBar = multiBar?.create(totalSize, 0, { + message: 'Hashing files ', + }); + const checkProgressBar = multiBar?.create(totalSize, 0, { + message: 'Checking for duplicates', + }); const newFiles: string[] = []; const duplicates: Asset[] = []; @@ -211,7 +242,13 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas } } - checkProgressBar?.increment(assets.length); + // Update progress based on total size of processed files + let processedSize = 0; + for (const asset of assets) { + const stats = statsMap.get(asset.id); + processedSize += stats?.size || 0; + } + checkProgressBar?.increment(processedSize); }, { concurrency, retry: 3 }, ); @@ -221,6 +258,10 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas const queue = new Queue( async (filepath: string): Promise => { + const stats = statsMap.get(filepath); + if (!stats) { + throw new Error(`Stats not found for ${filepath}`); + } const dto = { id: filepath, checksum: await sha1(filepath) }; results.push(dto); @@ -231,7 +272,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas void checkBulkUploadQueue.push(batch); } - hashProgressBar?.increment(); + hashProgressBar?.increment(stats.size); return results; }, { concurrency, retry: 3 }, @@ -362,23 +403,6 @@ export const uploadFiles = async ( const uploadFile = async (input: string, stats: Stats): Promise => { const { baseUrl, headers } = defaults; - const assetPath = path.parse(input); - const noExtension = path.join(assetPath.dir, assetPath.name); - - const sidecarsFiles = await Promise.all( - // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp - [`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => { - try { - const stats = await stat(sidecarPath); - return new UploadFile(sidecarPath, stats.size); - } catch { - return false; - } - }), - ); - - const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false); - const formData = new FormData(); formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, '')); formData.append('deviceId', 'CLI'); @@ -388,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise => { +export const findSidecar = (filepath: string): string | undefined => { + const assetPath = path.parse(filepath); + const noExtension = path.join(assetPath.dir, assetPath.name); + + // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp + for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) { + if (existsSync(sidecarPath)) { + return sidecarPath; + } + } +}; + +export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise => { let fileCount = 0; if (options.delete) { fileCount += uploaded.length; @@ -433,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo const chunkDelete = async (files: Asset[]) => { for (const assetBatch of chunk(files, options.concurrency)) { - await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath))); + await Promise.all( + assetBatch.map(async (input: Asset) => { + await unlink(input.filepath); + const sidecarPath = findSidecar(input.filepath); + if (sidecarPath) { + await unlink(sidecarPath); + } + }), + ); deletionProgress.update(assetBatch.length); } }; diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index f0011c6a24..1e1efa97b4 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -1,7 +1,15 @@ -import { getMyUser } from '@immich/sdk'; +import { getMyUser, Permission } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { mkdir, unlink } from 'node:fs/promises'; -import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils'; +import { + BaseOptions, + connect, + getAuthFilePath, + logError, + requirePermissions, + withError, + writeAuthFile, +} from 'src/utils'; export const login = async (url: string, key: string, options: BaseOptions) => { console.log(`Logging in to ${url}`); @@ -9,6 +17,7 @@ export const login = async (url: string, key: string, options: BaseOptions) => { const { configDirectory: configDir } = options; await connect(url, key); + await requirePermissions([Permission.UserRead]); const [error, user] = await withError(getMyUser()); if (error) { diff --git a/cli/src/commands/server-info.ts b/cli/src/commands/server-info.ts index bea49231c9..9a5098e628 100644 --- a/cli/src/commands/server-info.ts +++ b/cli/src/commands/server-info.ts @@ -1,8 +1,9 @@ -import { getAssetStatistics, getMyUser, getServerVersion, getSupportedMediaTypes } from '@immich/sdk'; -import { BaseOptions, authenticate } from 'src/utils'; +import { getAssetStatistics, getMyUser, getServerVersion, getSupportedMediaTypes, Permission } from '@immich/sdk'; +import { authenticate, BaseOptions, requirePermissions } from 'src/utils'; export const serverInfo = async (options: BaseOptions) => { const { url } = await authenticate(options); + await requirePermissions([Permission.ServerAbout, Permission.AssetStatistics, Permission.UserRead]); const [versionInfo, mediaTypes, stats, userInfo] = await Promise.all([ getServerVersion(), diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 9ef20b3679..38bd119459 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1,4 +1,4 @@ -import { getMyUser, init, isHttpError } from '@immich/sdk'; +import { ApiKeyResponseDto, getMyApiKey, getMyUser, init, isHttpError, Permission } from '@immich/sdk'; import { convertPathToPattern, glob } from 'fast-glob'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -34,6 +34,36 @@ export const authenticate = async (options: BaseOptions): Promise => { return auth; }; +export const s = (count: number) => (count === 1 ? '' : 's'); + +let _apiKey: ApiKeyResponseDto; +export const requirePermissions = async (permissions: Permission[]) => { + if (!_apiKey) { + _apiKey = await getMyApiKey(); + } + + if (_apiKey.permissions.includes(Permission.All)) { + return; + } + + const missing: Permission[] = []; + + for (const permission of permissions) { + if (!_apiKey.permissions.includes(permission)) { + missing.push(permission); + } + } + + if (missing.length > 0) { + const combined = missing.map((permission) => `"${permission}"`).join(', '); + console.log( + `Missing required permission${s(missing.length)}: ${combined}. +Please make sure your API key has the correct permissions.`, + ); + process.exit(1); + } +}; + export const connect = async (url: string, key: string) => { const wellKnownUrl = new URL('.well-known/immich', url); try { diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 244fc74dba..8c46d3c51f 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -14,33 +14,65 @@ name: immich-dev services: + immich-app-base: + profiles: ['_base'] + tmpfs: + - /tmp + volumes: + - ..:/usr/src/app + - pnpm_cache:/buildcache/pnpm_cache + - server_node_modules:/usr/src/app/server/node_modules + - web_node_modules:/usr/src/app/web/node_modules + - github_node_modules:/usr/src/app/.github/node_modules + - cli_node_modules:/usr/src/app/cli/node_modules + - docs_node_modules:/usr/src/app/docs/node_modules + - e2e_node_modules:/usr/src/app/e2e/node_modules + - sdk_node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app_node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage + + immich-init: + extends: + service: immich-app-base + profiles: !reset [] + container_name: immich_init + image: immich-server-dev:latest + build: + context: ../ + dockerfile: server/Dockerfile.dev + target: dev + command: + - | + pnpm install + touch /tmp/init-complete + exec tail -f /dev/null + volumes: + - pnpm_store_server:/buildcache/pnpm-store + restart: 'no' + healthcheck: + test: ['CMD', 'test', '-f', '/tmp/init-complete'] + interval: 2s + timeout: 3s + retries: 300 + start_period: 300s + immich-server: + extends: + service: immich-app-base + profiles: !reset [] container_name: immich_server command: ['immich-dev'] image: immich-server-dev:latest - # extends: - # file: hwaccel.transcoding.yml - # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding build: context: ../ dockerfile: server/Dockerfile.dev target: dev restart: unless-stopped volumes: - - ..:/usr/src/app - ${UPLOAD_LOCATION}/photos:/data - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_server:/buildcache/pnpm-store - ../plugins:/build/corePlugin env_file: - .env @@ -63,6 +95,8 @@ services: - 9231:9231 - 2283:2283 depends_on: + immich-init: + condition: service_healthy redis: condition: service_started database: @@ -71,6 +105,9 @@ services: disable: false immich-web: + extends: + service: immich-app-base + profiles: !reset [] container_name: immich_web image: immich-web-dev:latest build: @@ -84,20 +121,11 @@ services: - 3000:3000 - 24678:24678 volumes: - - ..:/usr/src/app - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_web:/buildcache/pnpm-store restart: unless-stopped depends_on: + immich-init: + condition: service_healthy immich-server: condition: service_started @@ -116,7 +144,7 @@ services: - 3003:3003 volumes: - ../machine-learning/immich_ml:/usr/src/immich_ml - - model-cache:/cache + - model_cache:/cache env_file: - .env depends_on: @@ -127,7 +155,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e healthcheck: test: redis-cli ping || exit 1 @@ -156,7 +184,7 @@ services: # image: prom/prometheus # volumes: # - ./prometheus.yml:/etc/prometheus/prometheus.yml - # - prometheus-data:/prometheus + # - prometheus_data:/prometheus # first login uses admin/admin # add data source for http://immich-prometheus:9090 to get started @@ -167,20 +195,22 @@ services: # - 3000:3000 # image: grafana/grafana:10.3.3-ubuntu # volumes: - # - grafana-data:/var/lib/grafana + # - grafana_data:/var/lib/grafana volumes: - model-cache: - prometheus-data: - grafana-data: - pnpm-store: - server-node_modules: - web-node_modules: - github-node_modules: - cli-node_modules: - docs-node_modules: - e2e-node_modules: - sdk-node_modules: - app-node_modules: + model_cache: + prometheus_data: + grafana_data: + pnpm_cache: + pnpm_store_server: + pnpm_store_web: + server_node_modules: + web_node_modules: + github_node_modules: + cli_node_modules: + docs_node_modules: + e2e_node_modules: + sdk_node_modules: + app_node_modules: sveltekit: coverage: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 217ec08030..4d9e7efbe9 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 95c224341b..f6eb38a429 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b8668cc91a..3d92655453 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docs/.nvmrc b/docs/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/docs/docs/developer/devcontainers.md b/docs/docs/developer/devcontainers.md index f50ec62d8a..4bd60262ad 100644 --- a/docs/docs/developer/devcontainers.md +++ b/docs/docs/developer/devcontainers.md @@ -44,7 +44,7 @@ While this guide focuses on VS Code, you have many options for Dev Container dev **Self-Hostable Options:** - [Coder](https://coder.com) - Enterprise-focused, requires Terraform knowledge, self-managed -- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise) +- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise). Check [quick-start guide](#quick-start-guide-for-devpod-with-docker) ::: ## Dev Container Services @@ -408,7 +408,27 @@ If you encounter issues: 1. Check container logs: View → Output → Select "Dev Containers" 2. Rebuild without cache: "Dev Containers: Rebuild Container Without Cache" 3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/) -4. Ask in [Discord](https://discord.immich.app) `#help-desk-support` channel +4. Ask in [Discord](https://discord.immich.app) `#contributing` channel + +### Quick-start guide for DevPod with docker + +You will need DevPod CLI (check [DevPod CLI installation guide](https://devpod.sh/docs/getting-started/install)) and Docker Desktop. + +```sh +# Step 1: Clone the Repository +git clone https://github.com/immich-app/immich.git +cd immich + +# Step 2: Prepare DevPod (if you haven't already) +devpod provider add docker +devpod provider use docker + +# Step 3: Build 'immich-server-dev' docker image first manually +docker build -f server/Dockerfile.dev -t immich-server-dev . + +# Step 4: Now you can start devcontainer +devpod up . +``` ## Mobile Development diff --git a/docs/docs/features/supported-formats.md b/docs/docs/features/supported-formats.md index 16f1ab0b6b..4c4ac6039a 100644 --- a/docs/docs/features/supported-formats.md +++ b/docs/docs/features/supported-formats.md @@ -38,6 +38,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a | `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | | | `MP4` | `.mp4` `.insv` | :white_check_mark: | | | `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | | +| `MXF` | `.mxf` | :white_check_mark: | | | `QUICKTIME` | `.mov` | :white_check_mark: | | | `WEBM` | `.webm` | :white_check_mark: | | | `WMV` | `.wmv` | :white_check_mark: | | diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index a6aaae149b..bf815521ef 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -8,7 +8,8 @@ A config file can be provided as an alternative to the UI configuration. ### Step 1 - Create a new config file -In JSON format, create a new config file (e.g. `immich.json`) and put it in a location that can be accessed by Immich. +In JSON format, create a new config file (e.g. `immich.json`) and put it in a location mounted in the container that can be accessed by Immich. +YAML-formatted config files are also supported. The default configuration looks like this:
@@ -251,6 +252,15 @@ So you can just grab it from there, paste it into a file and you're pretty much In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config. For more information, refer to the [Environment Variables](/install/environment-variables.md) section. -:::tip -YAML-formatted config files are also supported. -::: +:::info Docker Compose +In your `.env` file, the variables `UPLOAD_LOCATION` and `DB_DATA_LOCATION` concern the location on the host. +However, the variable `IMMICH_CONFIG_FILE` concerns the location inside the container, and informs the `immich-server` container that a configuration file is present. + +It is recommended to reuse this variable in your `docker-compose.yml`: + +```yaml +volumes: + - ./configuration.yml:${IMMICH_CONFIG_FILE} +``` + +:: diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md index 3e5b780db2..b86561dbbf 100644 --- a/docs/docs/install/synology.md +++ b/docs/docs/install/synology.md @@ -8,8 +8,6 @@ sidebar_position: 85 This is a community contribution and not officially supported by the Immich team, but included here for convenience. Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/). - -**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).** ::: Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager. diff --git a/docs/mise.toml b/docs/mise.toml index 4ffb7d5cce..32fcac5578 100644 --- a/docs/mise.toml +++ b/docs/mise.toml @@ -23,3 +23,9 @@ run = "prettier --check ." [tasks."format-fix"] env._.path = "./node_modules/.bin" run = "prettier --write ." + +[tasks.deploy] +run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}" + +[tools] +wrangler = "4.66.0" diff --git a/docs/package.json b/docs/package.json index 87b0b3fccd..8c270f013b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,7 +8,7 @@ "format:fix": "prettier --write .", "start": "docusaurus start --port 3005", "copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0", - "build": "npm run copy:openapi && docusaurus build", + "build": "pnpm run copy:openapi && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -58,6 +58,6 @@ "node": ">=20" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/docs/src/pages/errors.md b/docs/src/pages/errors.md index fed72f21c7..6189fcaae1 100644 --- a/docs/src/pages/errors.md +++ b/docs/src/pages/errors.md @@ -32,3 +32,7 @@ If you would like to migrate from one media location to another, simply successf 4. Start up Immich After version `1.136.0`, Immich can detect when a media location has moved and will automatically update the database paths to keep them in sync. + +## Schema drift + +Schema drift is when the database schema is out of sync with the code. This could be the result of manual database tinkering, issues during a database restore, or something else. Schema drift can lead to data corruption, application bugs, and other unpredictable behavior. Please reconcile the differences as soon as possible. Specifically, missing `CONSTRAINT`s can result in duplicate assets being uploaded, since the server relies on a checksum `CONSTRAINT` to prevent duplicates. diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/e2e/docker-compose.dev.yml b/e2e/docker-compose.dev.yml index 14e159ed50..b301ef8441 100644 --- a/e2e/docker-compose.dev.yml +++ b/e2e/docker-compose.dev.yml @@ -1,86 +1,77 @@ name: immich-e2e services: + immich-app-base: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-app-base + + immich-init: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-init + container_name: immich-e2e-init + immich-server: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-server container_name: immich-e2e-server - command: ['immich-dev'] - image: immich-server-dev:latest - build: - context: ../ - dockerfile: server/Dockerfile.dev - target: dev + ports: !reset [] + env_file: !reset [] environment: - - DB_HOSTNAME=database - - DB_USERNAME=postgres - - DB_PASSWORD=postgres - - DB_DATABASE_NAME=immich - - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_TELEMETRY_INCLUDE=all - - IMMICH_ENV=testing - - IMMICH_PORT=2285 - - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true + DB_HOSTNAME: database + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE_NAME: immich + IMMICH_MACHINE_LEARNING_ENABLED: 'false' + IMMICH_TELEMETRY_INCLUDE: all + IMMICH_ENV: testing + IMMICH_PORT: '2285' + IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true' volumes: - ./test-assets:/test-assets - - ..:/usr/src/app - - ${UPLOAD_LOCATION}/photos:/data - - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage - - ../plugins:/build/corePlugin depends_on: + immich-init: + condition: service_healthy redis: condition: service_started database: condition: service_healthy immich-web: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-web container_name: immich-e2e-web - image: immich-web-dev:latest - build: - context: ../ - dockerfile: server/Dockerfile.dev - target: dev - command: ['immich-web'] - ports: + ports: !override - 2285:3000 environment: - - IMMICH_SERVER_URL=http://immich-server:2285/ - volumes: - - ..:/usr/src/app - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + IMMICH_SERVER_URL: http://immich-server:2285/ + depends_on: + immich-init: + condition: service_healthy restart: unless-stopped redis: - image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef + extends: + file: ../docker/docker-compose.dev.yml + service: redis + container_name: immich-e2e-redis database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 + extends: + file: ../docker/docker-compose.dev.yml + service: database + container_name: immich-e2e-postgres command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf + env_file: !reset [] + ports: !override + - 5435:5432 environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: immich - ports: - - 5435:5432 healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] interval: 1s @@ -89,17 +80,19 @@ services: start_period: 10s volumes: - model-cache: - prometheus-data: - grafana-data: - pnpm-store: - server-node_modules: - web-node_modules: - github-node_modules: - cli-node_modules: - docs-node_modules: - e2e-node_modules: - sdk-node_modules: - app-node_modules: + model_cache: + prometheus_data: + grafana_data: + pnpm_cache: + pnpm_store_server: + pnpm_store_web: + server_node_modules: + web_node_modules: + github_node_modules: + cli_node_modules: + docs_node_modules: + e2e_node_modules: + sdk_node_modules: + app_node_modules: sveltekit: coverage: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index a98a7013a4..8ae5762a1b 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -2,6 +2,7 @@ name: immich-e2e services: e2e-auth-server: + container_name: immich-e2e-auth-server build: context: ../e2e-auth-server ports: @@ -22,15 +23,15 @@ services: - BUILD_SOURCE_REF=e2e - BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee environment: - - DB_HOSTNAME=database - - DB_USERNAME=postgres - - DB_PASSWORD=postgres - - DB_DATABASE_NAME=immich - - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_TELEMETRY_INCLUDE=all - - IMMICH_ENV=testing - - IMMICH_PORT=2285 - - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true + DB_HOSTNAME: database + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE_NAME: immich + IMMICH_MACHINE_LEARNING_ENABLED: 'false' + IMMICH_TELEMETRY_INCLUDE: all + IMMICH_ENV: testing + IMMICH_PORT: '2285' + IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true' volumes: - ./test-assets:/test-assets depends_on: @@ -42,10 +43,14 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef + container_name: immich-e2e-redis + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + healthcheck: + test: redis-cli ping || exit 1 database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 + container_name: immich-e2e-postgres + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf environment: POSTGRES_PASSWORD: postgres @@ -53,6 +58,7 @@ services: POSTGRES_DB: immich ports: - 5435:5432 + shm_size: 128mb healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] interval: 1s diff --git a/e2e/package.json b/e2e/package.json index 01dd036a2f..02facc450d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,12 +7,17 @@ "scripts": { "test": "vitest --run", "test:watch": "vitest", - "test:web": "npx playwright test", - "start:web": "npx playwright test --ui", + "test:maintenance": "vitest --run --config vitest.maintenance.config.ts", + "test:web": "pnpm exec playwright test --project=web", + "test:web:maintenance": "pnpm exec playwright test --project=maintenance", + "test:web:ui": "pnpm exec playwright test --project=ui", + "start:web": "pnpm exec playwright test --ui --project=web", + "start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance", + "start:web:ui": "pnpm exec playwright test --ui --project=ui", "format": "prettier --check .", "format:fix": "prettier --write .", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix", + "lint:fix": "pnpm run lint --fix", "check": "tsc --noEmit" }, "keywords": [], @@ -21,13 +26,13 @@ "devDependencies": { "@eslint/js": "^9.8.0", "@faker-js/faker": "^10.1.0", - "@immich/cli": "file:../cli", - "@immich/e2e-auth-server": "file:../e2e-auth-server", - "@immich/sdk": "file:../open-api/typescript-sdk", + "@immich/cli": "workspace:*", + "@immich/e2e-auth-server": "workspace:*", + "@immich/sdk": "workspace:*", "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", @@ -52,6 +57,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 032e6affbf..040546b7bb 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,7 +3,7 @@ import dotenv from 'dotenv'; import { cpus } from 'node:os'; import { resolve } from 'node:path'; -dotenv.config({ path: resolve(import.meta.dirname, '.env') }); +dotenv.config({ quiet: true, path: resolve(import.meta.dirname, '.env') }); export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1'; export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1'; @@ -48,7 +48,7 @@ const config: PlaywrightTestConfig = { { name: 'maintenance', use: { ...devices['Desktop Chrome'] }, - testDir: './src/specs/maintenance', + testDir: './src/specs/maintenance/web', workers: 1, }, ], diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 9585484355..3d7971d6f0 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -43,10 +43,10 @@ export const errorDto = { message: 'Invalid share key', correlationId: expect.any(String), }, - invalidSharePassword: { + passwordRequired: { error: 'Unauthorized', statusCode: 401, - message: 'Invalid password', + message: 'Password required', correlationId: expect.any(String), }, badRequest: (message: any = null) => ({ diff --git a/e2e/src/specs/server/api/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts similarity index 100% rename from e2e/src/specs/server/api/database-backups.e2e-spec.ts rename to e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts diff --git a/e2e/src/specs/server/api/maintenance.e2e-spec.ts b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts similarity index 100% rename from e2e/src/specs/server/api/maintenance.e2e-spec.ts rename to e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts diff --git a/e2e/src/specs/maintenance/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts similarity index 100% rename from e2e/src/specs/maintenance/database-backups.e2e-spec.ts rename to e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts diff --git a/e2e/src/specs/maintenance/maintenance.e2e-spec.ts b/e2e/src/specs/maintenance/web/maintenance.e2e-spec.ts similarity index 100% rename from e2e/src/specs/maintenance/maintenance.e2e-spec.ts rename to e2e/src/specs/maintenance/web/maintenance.e2e-spec.ts diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index d4eee16232..11e825a7cd 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -253,7 +253,8 @@ describe('/asset', () => { expect(status).toBe(200); expect(body.id).toEqual(facesAsset.id); - expect(body.people).toMatchObject(expectedFaces); + const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name)); + expect(sortedPeople).toMatchObject(expectedFaces); }); }); diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 8c15a14da5..80232beb75 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -239,7 +239,7 @@ describe('/shared-links', () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key }); expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidSharePassword); + expect(body).toEqual(errorDto.passwordRequired); }); it('should get data for correct password protected link', async () => { diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index 774839b174..e3799a7c3b 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -65,7 +65,7 @@ export const thumbnailUtils = { return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`); }, selectedAsset(page: Page) { - return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])'); + return page.locator('[data-thumbnail-focus-container][data-selected]'); }, async clickAssetId(page: Page, assetId: string) { await thumbnailUtils.withAssetId(page, assetId).click(); @@ -103,11 +103,8 @@ export const thumbnailUtils = { await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0); }, async expectSelectedReadonly(page: Page, assetId: string) { - // todo - need a data attribute for selected await expect( - page.locator( - `[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`, - ), + page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`), ).toBeVisible(); }, async expectTimelineHasOnScreenAssets(page: Page) { diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 953273a930..1312bf9b75 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,15 +1,20 @@ import { defineConfig } from 'vitest/config'; -// skip `docker compose up` if `make e2e` was already run +const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; + +// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set const globalSetup: string[] = []; -try { - await fetch('http://127.0.0.1:2285/api/server/ping'); -} catch { - globalSetup.push('src/docker-compose.ts'); +if (!skipDockerSetup) { + try { + await fetch('http://127.0.0.1:2285/api/server/ping'); + } catch { + globalSetup.push('src/docker-compose.ts'); + } } export default defineConfig({ test: { + retry: process.env.CI ? 4 : 0, include: ['src/specs/server/**/*.e2e-spec.ts'], globalSetup, testTimeout: 15_000, diff --git a/e2e/vitest.maintenance.config.ts b/e2e/vitest.maintenance.config.ts new file mode 100644 index 0000000000..6bb6721a6d --- /dev/null +++ b/e2e/vitest.maintenance.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; + +const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; + +// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set +const globalSetup: string[] = []; +if (!skipDockerSetup) { + try { + await fetch('http://127.0.0.1:2285/api/server/ping'); + } catch { + globalSetup.push('src/docker-compose.ts'); + } +} + +export default defineConfig({ + test: { + retry: process.env.CI ? 4 : 0, + include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'], + globalSetup, + testTimeout: 15_000, + pool: 'threads', + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, +}); diff --git a/i18n/ar.json b/i18n/ar.json index 364111922a..a1c29402c2 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -1613,7 +1613,6 @@ "not_available": "ØēŲŠØą Ų…ØĒاح", "not_in_any_album": "Ų„ŲŠØŗØĒ ؁؊ ØŖŲŠ ØŖŲ„Ø¨ŲˆŲ…", "not_selected": "Ų„Ų… ŲŠØŽØĒØ§Øą", - "note_apply_storage_label_to_previously_uploaded assets": "Ų…Ų„Ø§Ø­Ø¸ØŠ: Ų„ØĒØˇØ¨ŲŠŲ‚ ØŗŲ…ØŠ Ø§Ų„ØĒØŽØ˛ŲŠŲ† ØšŲ„Ų‰ Ø§Ų„Ų…Ø­ØĒŲˆŲŠØ§ØĒ Ø§Ų„ØĒ؊ ØĒŲ… ØąŲØšŲ‡Ø§ Ų…ØŗØ¨Ų‚Ų‹Ø§ØŒ Ų‚Ų… بØĒØ´ØēŲŠŲ„", "notes": "Ų…Ų„Ø§Ø­Ø¸Ø§ØĒ", "nothing_here_yet": "Ų„Ø§ ؊؈ØŦد Ø´ŲŠØĄ Ų‡Ų†Ø§ بؚد", "notification_permission_dialog_content": "Ų„ØĒŲ…ŲƒŲŠŲ† Ø§Ų„ØĨØŽØˇØ§ØąØ§ØĒ ، Ø§Ų†ØĒŲ‚Ų„ ØĨŲ„Ų‰ Ø§Ų„ØĨؚداداØĒ ؈ ا؎ØĒØ§Øą Ø§Ų„ØŗŲ…Ø§Ø­.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "ØĒŲ…ØĒ ØĨؚاد؊ ØĒØšŲŠŲŠŲ† {count, plural, one {# Ø§Ų„Ų…Ø­ØĒŲˆŲ‰} other {# Ø§Ų„Ų…Ø­ØĒŲˆŲŠØ§ØĒ}} ØĨŲ„Ų‰ Ø´ØŽØĩ ØŦØ¯ŲŠØ¯", "reassing_hint": "ØĒØšŲŠŲŠŲ† Ø§Ų„Ų…Ø­ØĒŲˆŲŠØ§ØĒ Ø§Ų„Ų…Ø­Ø¯Ø¯ØŠ Ų„Ø´ØŽØĩ Ų…ŲˆØŦŲˆØ¯", "recent": "Ø­Ø¯ŲŠØĢ", - "recent-albums": "ØŖŲ„Ø¨ŲˆŲ…Ø§ØĒ Ø§Ų„Ø­Ø¯ŲŠØĢØŠ", + "recent_albums": "ØŖŲ„Ø¨ŲˆŲ…Ø§ØĒ Ø§Ų„Ø­Ø¯ŲŠØĢØŠ", "recent_searches": "ØšŲ…Ų„ŲŠØ§ØĒ Ø§Ų„Ø¨Ø­ØĢ Ø§Ų„ØŖØŽŲŠØąØŠ", "recently_added": "اØļ؊؁ Ų…Ø¤ØŽØąØ§", "recently_added_page_title": "ØŖØļ؊؁ Ų…Ø¤ØŽØąØ§", diff --git a/i18n/be.json b/i18n/be.json index 13ac6747f1..1c446c0cbd 100644 --- a/i18n/be.json +++ b/i18n/be.json @@ -457,7 +457,7 @@ "reassign": "ПĐĩŅ€Đ°ĐŋŅ€Ņ‹ĐˇĐŊĐ°Ņ‡Ņ‹Ņ†ŅŒ", "reassing_hint": "ĐŸŅ€Ņ‹ĐŋŅ–ŅĐ°Ņ†ŅŒ Đ˛Ņ‹ĐąŅ€Đ°ĐŊŅ‹Ņ аĐēŅ‚Ņ‹Đ˛Ņ‹ ҖҁĐŊŅƒŅŽŅ‡Đ°Đš Đ°ŅĐžĐąĐĩ", "recent": "ĐŅĐ´Đ°ŅžĐŊŅ–", - "recent-albums": "ĐŅĐ´Đ°ŅžĐŊŅ–Ņ аĐģŅŒĐąĐžĐŧŅ‹", + "recent_albums": "ĐŅĐ´Đ°ŅžĐŊŅ–Ņ аĐģŅŒĐąĐžĐŧŅ‹", "recent_searches": "ĐŅĐ´Đ°ŅžĐŊŅ–Ņ ĐŋĐžŅˆŅƒĐēŅ–", "recently_added": "ĐŅĐ´Đ°ŅžĐŊа дададСĐĩĐŊа", "refresh_faces": "АйĐŊĐ°Đ˛Ņ–Ņ†ŅŒ Ņ‚Đ˛Đ°Ņ€Ņ‹", diff --git a/i18n/bg.json b/i18n/bg.json index f640e1be50..0d39878cad 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -1613,7 +1613,6 @@ "not_available": "НĐĩĐŊаĐģĐ¸Ņ‡ĐŊĐž", "not_in_any_album": "НĐĩ Đĩ в ĐŊиĐēОК аĐģĐąŅƒĐŧ", "not_selected": "НĐĩ Đĩ Đ¸ĐˇĐąŅ€Đ°ĐŊĐž", - "note_apply_storage_label_to_previously_uploaded assets": "ЗабĐĩĐģĐĩĐļĐēа: За да ĐŋŅ€Đ¸ĐģĐžĐļĐ¸Ņ‚Đĩ ĐĩŅ‚Đ¸ĐēĐĩŅ‚Đ° Са ŅŅŠŅ…Ņ€Đ°ĐŊĐĩĐŊиĐĩ ĐēҊĐŧ ĐŋŅ€ĐĩĐ´Đ˛Đ°Ņ€Đ¸Ņ‚ĐĩĐģĐŊĐž ĐēĐ°Ņ‡ĐĩĐŊи аĐēŅ‚Đ¸Đ˛Đ¸, ŅŅ‚Đ°Ņ€Ņ‚Đ¸Ņ€Đ°ĐšŅ‚Đĩ", "notes": "БĐĩĐģĐĩĐļĐēи", "nothing_here_yet": "Đ—Đ°ŅĐĩĐŗĐ° Ņ‚ŅƒĐē ĐŊŅĐŧа ĐŊĐ¸Ņ‰Đž", "notification_permission_dialog_content": "За да вĐēĐģŅŽŅ‡Đ¸Ņˆ иСвĐĩŅŅ‚Đ¸ŅŅ‚Đ°, ĐžŅ‚Đ¸Đ´Đ¸ в ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи и иСйĐĩŅ€Đ¸ Đ Đ°ĐˇŅ€ĐĩŅˆĐ¸.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "ĐŸŅ€ĐĩĐŊаСĐŊĐ°Ņ‡ĐĩĐŊи {count, plural, one {# ĐĩĐģĐĩĐŧĐĩĐŊŅ‚} other {# ĐĩĐģĐĩĐŧĐĩĐŊŅ‚Đ°}} ĐŊа ĐŊОв Ņ‡ĐžĐ˛ĐĩĐē", "reassing_hint": "НазĐŊĐ°Ņ‡Đ¸ Đ¸ĐˇĐąŅ€Đ°ĐŊĐ¸Ņ‚Đĩ ĐĩĐģĐĩĐŧĐĩĐŊŅ‚Đ¸ ĐŊа ŅŅŠŅ‰ĐĩŅŅ‚Đ˛ŅƒĐ˛Đ°Ņ‰Đž ĐģĐ¸Ņ†Đĩ", "recent": "ĐĄĐēĐžŅ€ĐžŅˆĐŊи", - "recent-albums": "ĐĄĐēĐžŅ€ĐžŅˆĐŊи АĐģĐąŅƒĐŧи", + "recent_albums": "ĐĄĐēĐžŅ€ĐžŅˆĐŊи АĐģĐąŅƒĐŧи", "recent_searches": "ĐĄĐēĐžŅ€ĐžŅˆĐŊи Ņ‚ŅŠŅ€ŅĐĩĐŊĐ¸Ņ", "recently_added": "ĐĐ°ŅĐēĐžŅ€Đž дОйавĐĩĐŊĐž", "recently_added_page_title": "ĐĐ°ŅĐēĐžŅ€Đž дОйавĐĩĐŊĐž", diff --git a/i18n/bi.json b/i18n/bi.json index c5c9edbbb1..290b816cc6 100644 --- a/i18n/bi.json +++ b/i18n/bi.json @@ -17,7 +17,7 @@ "readonly_mode_enabled": "Mod blo yu no save janjem i on", "reassigned_assets_to_new_person": "Janjem{count, plural, one {# asset} other {# assets}} blo nu man", "reassing_hint": "janjem ol sumtin yu bin joos i go blo wan man", - "recent-albums": "album i no old tu mas", + "recent_albums": "album i no old tu mas", "recent_searches": "lukabout wea i no old tu mas", "time_based_memories_duration": "hao mus second blo wan wan imij i stap lo scrin.", "timezone": "taemzon", diff --git a/i18n/ca.json b/i18n/ca.json index 43ec0f3285..563d5f15c5 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -311,7 +311,7 @@ "search_jobs": "Cercar treballsâ€Ļ", "send_welcome_email": "Enviar correu electrÃ˛nic de benvinguda", "server_external_domain_settings": "Domini extern", - "server_external_domain_settings_description": "Domini per enllaços pÃēblics compartits, incloent http(s)://", + "server_external_domain_settings_description": "Domini utilitzat per a enllaços externs", "server_public_users": "Usuaris pÃēblics", "server_public_users_description": "Tots els usuaris (nom i correu electrÃ˛nic) apareixen a la llista a l'afegir un usuari als àlbums compartits. Si es desactiva, la llista nomÊs serà disponible pels usuaris administradors.", "server_settings": "ConfiguraciÃŗ del servidor", @@ -794,6 +794,11 @@ "color": "Color", "color_theme": "Tema de color", "command": "Ordre", + "command_palette_prompt": "Trobar ràpidament pàgines, accions o comandes", + "command_palette_to_close": "per a tancar", + "command_palette_to_navigate": "per a introduir", + "command_palette_to_select": "per a seleccionar", + "command_palette_to_show_all": "per a mostrar-ho tot", "comment_deleted": "Comentari esborrat", "comment_options": "Opcions de comentari", "comments_and_likes": "Comentaris i agradaments", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "PERSONES", "exif_bottom_sheet_person_add_person": "Afegir nom", "exit_slideshow": "Surt de la presentaciÃŗ de diapositives", + "expand": "Ampliar-ho", "expand_all": "Ampliar-ho tot", "experimental_settings_new_asset_list_subtitle": "Treball en curs", "experimental_settings_new_asset_list_title": "Habilita la graella de fotos experimental", @@ -1532,7 +1538,7 @@ "mobile_app_download_onboarding_note": "Descarregar la App de mÃ˛bil fent servir les seguents opcions", "model": "Model", "month": "Mes", - "monthly_title_text_date_format": "MMMM y", + "monthly_title_text_date_format": "MMMM a", "more": "MÊs", "move": "Moure", "move_down": "Moure cap avall", @@ -1613,7 +1619,6 @@ "not_available": "N/A", "not_in_any_album": "En cap àlbum", "not_selected": "No seleccionat", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: per aplicar l'etiqueta d'emmagatzematge als actius penjats anteriorment, executeu el", "notes": "Notes", "nothing_here_yet": "No hi ha res encara", "notification_permission_dialog_content": "Per activar les notificacions, aneu a ConfiguraciÃŗ i seleccioneu permet.", @@ -1643,6 +1648,7 @@ "online": "En línia", "only_favorites": "NomÊs preferits", "open": "Obrir", + "open_calendar": "Obrir el calendari", "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", @@ -1815,7 +1821,7 @@ "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-albums": "Àlbums recents", + "recent_albums": "Àlbums recents", "recent_searches": "Cerques recents", "recently_added": "Afegit recentment", "recently_added_page_title": "Afegit recentment", @@ -2184,6 +2190,7 @@ "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.", + "supporter": "Contribuïdor", "swap_merge_direction": "Canvia la direcciÃŗ d'uniÃŗ", "sync": "Sincronitza", "sync_albums": "Sincronitzar àlbums", diff --git a/i18n/cs.json b/i18n/cs.json index fee372c524..77da129f83 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -311,7 +311,7 @@ "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)://", + "server_external_domain_settings_description": "DomÊna pouŞívanÃĄ pro externí odkazy", "server_public_users": "Veřejní uÅživatelÊ", "server_public_users_description": "VÅĄichni uÅživatelÊ (jmÊno a e-mail) jsou uvedeni při přidÃĄvÃĄní uÅživatele do sdílenÃŊch alb. Pokud je tato funkce vypnuta, bude seznam uÅživatelů dostupnÃŊ pouze uÅživatelům z řad sprÃĄvců.", "server_settings": "Server", @@ -794,6 +794,11 @@ "color": "Barva", "color_theme": "BarevnÃŊ motiv", "command": "Příkaz", + "command_palette_prompt": "RychlÊ vyhledÃĄvÃĄní strÃĄnek, akcí nebo příkazů", + "command_palette_to_close": "zavřít", + "command_palette_to_navigate": "vstoupit", + "command_palette_to_select": "vybrat", + "command_palette_to_show_all": "zobrazit vÅĄe", "comment_deleted": "KomentÃĄÅ™ odstraněn", "comment_options": "MoÅžnosti komentÃĄÅ™e", "comments_and_likes": "KomentÃĄÅ™e a lajky", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "LIDÉ", "exif_bottom_sheet_person_add_person": "Přidat jmÊno", "exit_slideshow": "Ukončit prezentaci", + "expand": "Rozbalit", "expand_all": "Rozbalit vÅĄe", "experimental_settings_new_asset_list_subtitle": "ZpracovÃĄvÃĄm", "experimental_settings_new_asset_list_title": "Povolení experimentÃĄlní mříŞky fotografií", @@ -1613,7 +1619,6 @@ "not_available": "Není k dispozici", "not_in_any_album": "Bez alba", "not_selected": "Není vybrÃĄno", - "note_apply_storage_label_to_previously_uploaded assets": "Upozornění: Chcete-li pouŞít ÅĄtítek ÃēloÅžiÅĄtě na dříve nahranÊ poloÅžky, spusÅĨte příkaz", "notes": "PoznÃĄmky", "nothing_here_yet": "Zatím zde nic není", "notification_permission_dialog_content": "Chcete-li povolit oznÃĄmení, přejděte do nastavení a vyberte moÅžnost povolit.", @@ -1643,6 +1648,7 @@ "online": "Online", "only_favorites": "Pouze oblíbenÊ", "open": "Otevřít", + "open_calendar": "Otevřít kalendÃĄÅ™", "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", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {Přeřazena # poloÅžka} few {Přeřazeny # poloÅžky} other {Přeřazeno # poloÅžek}} na novou osobu", "reassing_hint": "Přiřazení vybranÃŊch poloÅžek existující osobě", "recent": "NedÃĄvnÊ", - "recent-albums": "NedÃĄvnÃĄ alba", + "recent_albums": "NedÃĄvnÃĄ alba", "recent_searches": "NedÃĄvnÃĄ vyhledÃĄvÃĄní", "recently_added": "NedÃĄvno přidanÊ", "recently_added_page_title": "NedÃĄvno přidanÊ", @@ -2184,6 +2190,7 @@ "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ů.", + "supporter": "Podporovatel", "swap_merge_direction": "ObrÃĄtit směr sloučení", "sync": "Synchronizovat", "sync_albums": "Synchronizovat alba", diff --git a/i18n/da.json b/i18n/da.json index a3ff895b52..6981d6dae3 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -1613,7 +1613,6 @@ "not_available": "ikke tilgÃĻngelig", "not_in_any_album": "Ikke i noget album", "not_selected": "Ikke valgt", - "note_apply_storage_label_to_previously_uploaded assets": "BemÃĻrk: For at anvende LagringsmÃĻrkat pÃĨ tidligere uploadede medier, kør opgaven igen", "notes": "Noter", "nothing_here_yet": "Intet her endnu", "notification_permission_dialog_content": "GÃĨ til indstillinger for at slÃĨ notifikationer til.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "Gentildelt {count, plural, one {# aktiv} other {# aktiver}} til en ny person", "reassing_hint": "Tildel valgte mediefiler til en eksisterende person", "recent": "For nylig", - "recent-albums": "Seneste albums", + "recent_albums": "Seneste albums", "recent_searches": "Seneste søgninger", "recently_added": "Senest tilføjet", "recently_added_page_title": "Nyligt tilføjet", diff --git a/i18n/de.json b/i18n/de.json index 4a999023d0..b32ac57aba 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1,8 +1,8 @@ { - "about": "Über", + "about": "Über Immich", "account": "Konto", "account_settings": "Kontoeinstellungen", - "acknowledge": "Bestätigen", + "acknowledge": "Verstanden", "action": "Aktion", "action_common_update": "Aktualisieren", "action_description": "Eine Reihe von Aktionen, die an den gefilterten Assets ausgefÃŧhrt werden sollen", @@ -1613,7 +1613,6 @@ "not_available": "N/A", "not_in_any_album": "In keinem Album", "not_selected": "Nicht ausgewählt", - "note_apply_storage_label_to_previously_uploaded assets": "Hinweis: Um eine Speicherpfadbezeichnung anzuwenden, starte den", "notes": "Notizen", "nothing_here_yet": "Noch nichts hier", "notification_permission_dialog_content": "Um Benachrichtigungen zu aktivieren, navigiere zu Einstellungen und klicke \"Erlauben\".", @@ -1815,7 +1814,7 @@ "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": "Neueste", - "recent-albums": "Neueste Alben", + "recent_albums": "Neueste Alben", "recent_searches": "Letzte Suchen", "recently_added": "KÃŧrzlich hinzugefÃŧgt", "recently_added_page_title": "Zuletzt hinzugefÃŧgt", diff --git a/i18n/el.json b/i18n/el.json index d5de1b6add..b1a868023e 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -1613,7 +1613,6 @@ "not_available": "Μ/Δ (Μη Î”ÎšÎąÎ¸Î­ĪƒÎšÎŧÎŋ)", "not_in_any_album": "ÎŖÎĩ ÎēÎąÎŊέÎŊÎą ÎŦÎģÎŧĪ€ÎŋĪ…Îŧ", "not_selected": "ΔÎĩÎŊ ÎĩĪ€ÎšÎģÎ­Ī‡Î¸ÎˇÎēÎĩ", - "note_apply_storage_label_to_previously_uploaded assets": "ÎŖÎˇÎŧÎĩÎ¯Ī‰ĪƒÎˇ: Για ÎŊÎą ÎĩĪ†ÎąĪÎŧΌ΃ÎĩĪ„Îĩ Ī„ÎˇÎŊ Î•Ī„ÎšÎēÎ­Ī„Îą Î‘Ī€ÎŋθΎÎēÎĩĪ…ĪƒÎˇĪ‚ ΃Îĩ ĪƒĪ„ÎŋÎšĪ‡ÎĩÎ¯Îą Ī€ÎŋĪ… Î­Ī‡ÎŋĪ…ÎŊ ÎŧÎĩĪ„ÎąĪ†ÎŋĪĪ„Ī‰Î¸Îĩί ΀΁ÎŋÎˇÎŗÎŋĪ…ÎŧέÎŊΉ΂, ÎĩÎēĪ„ÎĩÎģÎ­ĪƒĪ„Îĩ Ī„Îŋ", "notes": "ÎŖÎˇÎŧÎĩÎšĪŽĪƒÎĩÎšĪ‚", "nothing_here_yet": "Î¤Î¯Ī€ÎŋĪ„Îą ÎĩÎ´ĪŽ ÎąÎēΌÎŧÎą", "notification_permission_dialog_content": "Για ÎŊÎą ÎĩÎŊÎĩĪÎŗÎŋĪ€ÎŋÎšÎŽĪƒÎĩĪ„Îĩ Ī„ÎšĪ‚ ÎĩΚδÎŋĪ€ÎŋÎšÎŽĪƒÎĩÎšĪ‚, ÎŧÎĩĪ„ÎąÎ˛ÎĩÎ¯Ī„Îĩ ĪƒĪ„ÎšĪ‚ ÎĄĪ…Î¸ÎŧÎ¯ĪƒÎĩÎšĪ‚ ÎēιΚ ÎĩĪ€ÎšÎģÎ­ÎžĪ„Îĩ ÎŊÎą ÎĩĪ€ÎšĪ„ĪÎ­Ī€ÎĩĪ„ÎąÎš.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "Η ÎąÎŊÎŦθÎĩĪƒÎˇ {count, plural, one {# ÎąĪĪ‡ÎĩίÎŋĪ…} other {# ÎąĪĪ‡ÎĩÎ¯Ī‰ÎŊ}} ΃Îĩ ÎŊέÎŋ ÎŦĪ„ÎŋÎŧÎŋ", "reassing_hint": "ΑÎŊÎŦθÎĩĪƒÎˇ ΄ΉÎŊ ÎĩĪ€ÎšÎģÎĩÎŗÎŧέÎŊΉÎŊ ĪƒĪ„ÎŋÎšĪ‡ÎĩÎ¯Ī‰ÎŊ ΃Îĩ Ī…Ī€ÎŦ΁·ÎŋÎŊ ÎŦĪ„ÎŋÎŧÎŋ", "recent": "Î ĪĪŒĪƒĪ†ÎąĪ„Îą", - "recent-albums": "Î ĪĪŒĪƒĪ†ÎąĪ„Îą ÎŦÎģÎŧĪ€ÎŋĪ…Îŧ", + "recent_albums": "Î ĪĪŒĪƒĪ†ÎąĪ„Îą ÎŦÎģÎŧĪ€ÎŋĪ…Îŧ", "recent_searches": "Î ĪĪŒĪƒĪ†ÎąĪ„ÎĩĪ‚ ÎąÎŊÎąÎļÎˇĪ„ÎŽĪƒÎĩÎšĪ‚", "recently_added": "Î ĪÎŋĪƒĪ„Î­Î¸ÎˇÎēÎąÎŊ Ī€ĪĪŒĪƒĪ†ÎąĪ„Îą", "recently_added_page_title": "Î ĪÎŋĪƒĪ„Î­Î¸ÎˇÎēÎąÎŊ Î ĪĪŒĪƒĪ†ÎąĪ„Îą", diff --git a/i18n/en.json b/i18n/en.json index 4ef350043a..a8e34c9209 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -323,7 +323,7 @@ "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)://", + "server_external_domain_settings_description": "Domain used for external links", "server_public_users": "Public Users", "server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.", "server_settings": "Server Settings", @@ -806,6 +806,11 @@ "color": "Color", "color_theme": "Color theme", "command": "Command", + "command_palette_prompt": "Quickly find pages, actions, or commands", + "command_palette_to_close": "to close", + "command_palette_to_navigate": "to enter", + "command_palette_to_select": "to select", + "command_palette_to_show_all": "to show all", "comment_deleted": "Comment deleted", "comment_options": "Comment options", "comments_and_likes": "Comments & likes", @@ -1180,6 +1185,7 @@ "exif_bottom_sheet_people": "PEOPLE", "exif_bottom_sheet_person_add_person": "Add name", "exit_slideshow": "Exit Slideshow", + "expand": "Expand", "expand_all": "Expand all", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", @@ -1225,6 +1231,7 @@ "filter_description": "Conditions to filter the target assets", "filter_people": "Filter people", "filter_places": "Filter places", + "filter_tags": "Filter tags", "filters": "Filters", "find_them_fast": "Find them fast by name with search", "first": "First", @@ -1628,7 +1635,6 @@ "not_available": "N/A", "not_in_any_album": "Not in any album", "not_selected": "Not selected", - "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the", "notes": "Notes", "nothing_here_yet": "Nothing here yet", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", @@ -1658,6 +1664,7 @@ "online": "Online", "only_favorites": "Only favorites", "open": "Open", + "open_calendar": "Open calendar", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", @@ -1830,7 +1837,7 @@ "reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person", "reassing_hint": "Assign selected assets to an existing person", "recent": "Recent", - "recent-albums": "Recent albums", + "recent_albums": "Recent albums", "recent_searches": "Recent searches", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", @@ -1954,6 +1961,7 @@ "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", "search_filter_star_rating": "Star Rating", + "search_filter_tags_title": "Select tags", "search_for": "Search for", "search_for_existing_person": "Search for existing person", "search_no_more_result": "No more results", @@ -2033,6 +2041,9 @@ "set_profile_picture": "Set profile picture", "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen", "set_stack_primary_asset": "Set as primary asset", + "setting_image_navigation_enable_subtitle": "If enabled, you can navigate to the previous/next image by tapping the leftmost/rightmost quarter of the screen.", + "setting_image_navigation_enable_title": "Tap to Navigate", + "setting_image_navigation_title": "Image Navigation", "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", "setting_image_viewer_original_title": "Load original image", @@ -2199,6 +2210,7 @@ "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.", + "supporter": "Supporter", "swap_merge_direction": "Swap merge direction", "sync": "Sync", "sync_albums": "Sync albums", diff --git a/i18n/es.json b/i18n/es.json index 091125f1af..49e58e3beb 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -311,7 +311,7 @@ "search_jobs": "Buscar trabajosâ€Ļ", "send_welcome_email": "Enviar correo de bienvenida", "server_external_domain_settings": "Dominio externo", - "server_external_domain_settings_description": "Dominio para enlaces pÃēblicos compartidos, incluidos http(s)://", + "server_external_domain_settings_description": "Dominio usado para enlaces externos", "server_public_users": "Usuarios pÃēblicos", "server_public_users_description": "Cuando se aÃąade un usuario a los ÃĄlbumes compartidos, todos los usuarios aparecen en una lista con su nombre y su correo electrÃŗnico. Si deshabilita esta opciÃŗn, solo los administradores podrÃĄn ver la lista de usuarios.", "server_settings": "ConfiguraciÃŗn del servidor", @@ -794,6 +794,11 @@ "color": "Color", "color_theme": "Color del tema", "command": "Comando", + "command_palette_prompt": "Encuentra rÃĄpidamente pÃĄginas, acciones o comandos", + "command_palette_to_close": "para cerrar", + "command_palette_to_navigate": "para entrar", + "command_palette_to_select": "para seleccionar", + "command_palette_to_show_all": "para mostrar todo", "comment_deleted": "Comentario borrado", "comment_options": "Opciones de comentarios", "comments_and_likes": "Comentarios y me gusta", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "PERSONAS", "exif_bottom_sheet_person_add_person": "AÃąadir nombre", "exit_slideshow": "Salir de la presentaciÃŗn", + "expand": "Expandir", "expand_all": "Expandir todo", "experimental_settings_new_asset_list_subtitle": "Trabajo en progreso", "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotogrÃĄfica experimental", @@ -1613,7 +1619,6 @@ "not_available": "N/D", "not_in_any_album": "Sin ÃĄlbum", "not_selected": "No seleccionado", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar la etiqueta de almacenamiento a los recursos que ya se subieron, ejecute la", "notes": "Notas", "nothing_here_yet": "Sin nada aÃēn", "notification_permission_dialog_content": "Para activar las notificaciones, ve a ConfiguraciÃŗn y selecciona permitir.", @@ -1643,6 +1648,7 @@ "online": "En línea", "only_favorites": "Solo favoritos", "open": "Abierto", + "open_calendar": "Abrir calendario", "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", @@ -1765,7 +1771,7 @@ "profile_picture_set": "Conjunto de imÃĄgenes de perfil.", "public_album": "Álbum pÃēblico", "public_share": "Compartir pÃēblicamente", - "purchase_account_info": "Seguidor", + "purchase_account_info": "Colaborador", "purchase_activated_subtitle": "Gracias por apoyar a Immich y al software de cÃŗdigo abierto", "purchase_activated_time": "Activado el {date}", "purchase_activated_title": "Su clave ha sido activada correctamente", @@ -1778,7 +1784,7 @@ "purchase_button_select": "Seleccionar", "purchase_failed_activation": "ÂĄError al activar! ÂĄPor favor, revisa tu correo electrÃŗnico para obtener la clave del producto correcta!", "purchase_individual_description_1": "Para un usuario", - "purchase_individual_description_2": "Estado de soporte", + "purchase_individual_description_2": "Estatus de colaborador", "purchase_individual_title": "Individual", "purchase_input_suggestion": "ÂŋTiene una clave de producto? IntrodÃēzcala a continuaciÃŗn", "purchase_license_subtitle": "Compre Immich para apoyar el desarrollo continuo del servicio", @@ -1794,7 +1800,7 @@ "purchase_remove_server_product_key": "Eliminar la clave de producto del servidor", "purchase_remove_server_product_key_prompt": "ÂŋEstÃĄ seguro de que desea eliminar la clave de producto del servidor?", "purchase_server_description_1": "Para todo el servidor", - "purchase_server_description_2": "Estado del soporte", + "purchase_server_description_2": "Estatus de colaborador", "purchase_server_title": "Servidor", "purchase_settings_server_activated": "La clave del producto del servidor la administra el administrador", "query_asset_id": "Consultar ID de recurso", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# recurso} other {# recursos}} a un nuevo usuario", "reassing_hint": "Asignar recursos seleccionados a una persona existente", "recent": "Reciente", - "recent-albums": "Últimos ÃĄlbumes", + "recent_albums": "Últimos ÃĄlbumes", "recent_searches": "BÃēsquedas recientes", "recently_added": "AÃąadidos recientemente", "recently_added_page_title": "ReciÊn aÃąadidos", @@ -2184,6 +2190,7 @@ "support": "Soporte", "support_and_feedback": "Soporte y comentarios", "support_third_party_description": "Esta instalaciÃŗn de Immich fue empaquetada por un tercero. Los problemas actuales pueden ser ocasionados por ese paquete; por favor, discuta sus inconvenientes con el empaquetador antes de usar los enlaces de abajo.", + "supporter": "Colaborador", "swap_merge_direction": "Alternar direcciÃŗn de mezcla", "sync": "Sincronizar", "sync_albums": "Sincronizar ÃĄlbumes", diff --git a/i18n/et.json b/i18n/et.json index 38d7903ab0..7b85dcfda6 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -311,7 +311,7 @@ "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_external_domain_settings_description": "Domeen väliste linkide jaoks", "server_public_users": "Avalikud kasutajad", "server_public_users_description": "Kasutaja jagatud albumisse lisamisel kuvatakse kÃĩiki kasutajaid (nime ja e-posti aadressiga). Kui keelatud, kuvatakse kasutajate nimekirja ainult administraatoritele.", "server_settings": "Serveri seaded", @@ -794,6 +794,11 @@ "color": "Värv", "color_theme": "Värviteema", "command": "Käsk", + "command_palette_prompt": "Leia kiirelt lehti, tegevusi vÃĩi käske", + "command_palette_to_close": "sulge", + "command_palette_to_navigate": "sisene", + "command_palette_to_select": "vali", + "command_palette_to_show_all": "näita kÃĩiki", "comment_deleted": "Kommentaar kustutatud", "comment_options": "Kommentaari valikud", "comments_and_likes": "Kommentaarid ja meeldimised", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "ISIKUD", "exif_bottom_sheet_person_add_person": "Lisa nimi", "exit_slideshow": "Sulge slaidiesitlus", + "expand": "Laienda", "expand_all": "Näita kÃĩik", "experimental_settings_new_asset_list_subtitle": "TÃļÃļs", "experimental_settings_new_asset_list_title": "Luba eksperimentaalne fotoruudistik", @@ -1613,7 +1619,6 @@ "not_available": "Pole saadaval", "not_in_any_album": "Pole Ãŧheski albumis", "not_selected": "Ei ole valitud", - "note_apply_storage_label_to_previously_uploaded assets": "Märkus: Et rakendada talletussilt varem Ãŧleslaaditud Ãŧksustele, käivita", "notes": "Märkused", "nothing_here_yet": "Siin pole veel midagi", "notification_permission_dialog_content": "Teavituste lubamiseks mine Seadetesse ja vali lubamine.", @@ -1643,6 +1648,7 @@ "online": "Ühendatud", "only_favorites": "Ainult lemmikud", "open": "Ava", + "open_calendar": "Ava kalender", "open_in_map_view": "Ava kaardi vaates", "open_in_openstreetmap": "Ava OpenStreetMap", "open_the_search_filters": "Ava otsingufiltrid", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# Ãŧksus} other {# Ãŧksust}} seostatud uue isikuga", "reassing_hint": "Seosta valitud Ãŧksused olemasoleva isikuga", "recent": "Hiljutine", - "recent-albums": "Hiljutised albumid", + "recent_albums": "Hiljutised albumid", "recent_searches": "Hiljutised otsingud", "recently_added": "Hiljuti lisatud", "recently_added_page_title": "Hiljuti lisatud", @@ -2184,6 +2190,7 @@ "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.", + "supporter": "Toetaja", "swap_merge_direction": "Muuda Ãŧhendamise suunda", "sync": "SÃŧnkrooni", "sync_albums": "SÃŧnkrooni albumid", diff --git a/i18n/fi.json b/i18n/fi.json index a47cd53933..425e7a719e 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -1568,7 +1568,6 @@ "not_available": "N/A", "not_in_any_album": "Ei yhdessäkään albumissa", "not_selected": "Ei valittu", - "note_apply_storage_label_to_previously_uploaded assets": "Huom: Jotta voit soveltaa tallennustunnistetta aiemmin ladattuihin kohteisiin, suorita", "notes": "Muistiinpanot", "nothing_here_yet": "Ei vielä mitään", "notification_permission_dialog_content": "Ottaaksesi ilmoitukset käyttÃļÃļn, siirry asetuksiin ja valitse 'salli'.", @@ -1767,7 +1766,7 @@ "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-albums": "Viimeisimmät albumit", + "recent_albums": "Viimeisimmät albumit", "recent_searches": "Edelliset haut", "recently_added": "Viimeksi lisätty", "recently_added_page_title": "Viimeksi lisätyt", diff --git a/i18n/fr.json b/i18n/fr.json index 51a5f542e3..7dc9e80e21 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -311,7 +311,7 @@ "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)://", + "server_external_domain_settings_description": "Nom de domaine utilisÊ pour les liens externes", "server_public_users": "Utilisateurs publics", "server_public_users_description": "Tous les utilisateurs (nom et courriel) sont listÊs lors de l'ajout d'un utilisateur à des albums partagÊs. Quand cela est dÊsactivÊ, la liste des utilisateurs est uniquement disponible pour les comptes administrateurs.", "server_settings": "Paramètres du serveur", @@ -794,6 +794,11 @@ "color": "Couleur", "color_theme": "Thème de couleur", "command": "Commande", + "command_palette_prompt": "Trouver rapidement des pages, actions ou commandes", + "command_palette_to_close": "pour fermer", + "command_palette_to_navigate": "pour entrer", + "command_palette_to_select": "pour sÊlectionner", + "command_palette_to_show_all": "pour tout afficher", "comment_deleted": "Commentaire supprimÊ", "comment_options": "Options des commentaires", "comments_and_likes": "Commentaires et \"J'aime\"", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "PERSONNES", "exif_bottom_sheet_person_add_person": "Ajouter un nom", "exit_slideshow": "Quitter le diaporama", + "expand": "DÊvelopper", "expand_all": "Tout dÊvelopper", "experimental_settings_new_asset_list_subtitle": "En cours de dÊveloppement", "experimental_settings_new_asset_list_title": "Activer la grille de photos expÊrimentale", @@ -1613,7 +1619,6 @@ "not_available": "N/A", "not_in_any_album": "Dans aucun album", "not_selected": "Non sÊlectionnÊ", - "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'Êtiquette de stockage aux mÊdias prÊcÊdemment envoyÊs, exÊcutez", "notes": "Notes", "nothing_here_yet": "Rien pour le moment", "notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sÊlectionnez Autoriser.", @@ -1643,6 +1648,7 @@ "online": "En ligne", "only_favorites": "Uniquement les favoris", "open": "Ouvrir", + "open_calendar": "Ouvrir le calendrier", "open_in_map_view": "Montrer sur la carte", "open_in_openstreetmap": "Ouvrir dans OpenStreetMap", "open_the_search_filters": "Ouvrir les filtres de recherche", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# mÊdia rÊattribuÊ} other {# mÊdias rÊattribuÊs}} à une nouvelle personne", "reassing_hint": "Attribuer ces mÊdias à une personne existante", "recent": "RÊcent", - "recent-albums": "Albums rÊcents", + "recent_albums": "Albums rÊcents", "recent_searches": "Recherches rÊcentes", "recently_added": "RÊcemment ajoutÊ", "recently_added_page_title": "RÊcemment ajoutÊ", @@ -2184,6 +2190,7 @@ "support": "Soutenir", "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.", + "supporter": "Contributeur", "swap_merge_direction": "Inverser la direction de fusion", "sync": "Synchroniser", "sync_albums": "Synchroniser dans des albums", diff --git a/i18n/ga.json b/i18n/ga.json index 665b95763f..409cc293d6 100644 --- a/i18n/ga.json +++ b/i18n/ga.json @@ -311,7 +311,7 @@ "search_jobs": "Cuardaigh poistâ€Ļ", "send_welcome_email": "Seol ríomhphost fÃĄilte", "server_external_domain_settings": "Fearann seachtrach", - "server_external_domain_settings_description": "Fearann le haghaidh naisc chomhroinnte poiblí, lena n-ÃĄirítear http(s)://", + "server_external_domain_settings_description": "Fearann a ÃēsÃĄidtear le haghaidh naisc sheachtracha", "server_public_users": "ÚsÃĄideoirí Poiblí", "server_public_users_description": "Liostaítear gach ÃēsÃĄideoir (ainm agus ríomhphost) nuair a chuirtear ÃēsÃĄideoir le halbaim chomhroinnte. Nuair a bhíonn sÊ díchumasaithe, ní bheidh an liosta ÃēsÃĄideoirí ar fÃĄil ach d’ÃēsÃĄideoirí riarthÃŗra.", "server_settings": "Socruithe Freastalaí", @@ -794,6 +794,11 @@ "color": "Dath", "color_theme": "TÊama datha", "command": "OrdÃē", + "command_palette_prompt": "Aimsigh leathanaigh, gníomhartha nÃŗ orduithe go tapa", + "command_palette_to_close": "a dhÃēnadh", + "command_palette_to_navigate": "dul isteach", + "command_palette_to_select": "a roghnÃē", + "command_palette_to_show_all": "chun gach rud a thaispeÃĄint", "comment_deleted": "TrÃĄcht scriosta", "comment_options": "Roghanna trÃĄchta", "comments_and_likes": "TrÃĄchtanna & Is maith liom", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "DAOINE", "exif_bottom_sheet_person_add_person": "Cuir ainm leis", "exit_slideshow": "Scoir an TaispeÃĄntais SleamhnÃĄn", + "expand": "Leathnaigh", "expand_all": "Leathnaigh gach rud", "experimental_settings_new_asset_list_subtitle": "Obair ar siÃēl", "experimental_settings_new_asset_list_title": "Cumasaigh eangach grianghraf turgnamhach", @@ -1613,7 +1619,6 @@ "not_available": "N/B", "not_in_any_album": "Ní in aon albam", "not_selected": "Níor roghnaíodh", - "note_apply_storage_label_to_previously_uploaded assets": "NÃŗta: Chun an LipÊad StÃŗrÃĄla a chur i bhfeidhm ar shÃŗcmhainní a uaslÃŗdÃĄileadh roimhe seo, rith an", "notes": "NÃŗtaí", "nothing_here_yet": "Níl aon rud anseo fÃŗs", "notification_permission_dialog_content": "Chun fÃŗgraí a chumasÃē, tÊigh go Socruithe agus roghnaigh ceadaigh.", @@ -1643,6 +1648,7 @@ "online": "Ar líne", "only_favorites": "Is fearr leat amhÃĄin", "open": "Oscail", + "open_calendar": "Oscail an fÊilire", "open_in_map_view": "Oscail i radharc lÊarscÃĄile", "open_in_openstreetmap": "Oscail in OpenStreetMap", "open_the_search_filters": "Oscail na scagairí cuardaigh", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "Athshannadh {count, plural, one {# sÃŗcmhainn} other {# sÃŗcmhainní}} do dhuine nua", "reassing_hint": "Sannadh sÃŗcmhainní roghnaithe do dhuine atÃĄ ann cheana fÊin", "recent": "Le dÊanaí", - "recent-albums": "Albaim le dÊanaí", + "recent_albums": "Albaim le dÊanaí", "recent_searches": "Cuardaigh le dÊanaí", "recently_added": "Cuireadh leis le dÊanaí", "recently_added_page_title": "Curtha leis le DÊanaí", @@ -2184,6 +2190,7 @@ "support": "Tacaíocht", "support_and_feedback": "Tacaíocht & Aiseolas", "support_third_party_description": "Rinne tríÃē pÃĄirtí pacÃĄiste de do shuiteÃĄil Immich. D’fhÊadfadh sÊ gur an pacÃĄiste sin ba chÃēis le fadhbanna a bhíonn agat, mar sin tabhair ceisteanna dÃŗibh ar dtÃēs trí na naisc thíos a ÃēsÃĄid.", + "supporter": "Tacaíochtaí", "swap_merge_direction": "Malartaigh treo an chumaisc", "sync": "SioncrÃŗnaigh", "sync_albums": "SioncrÃŗnaigh albaim", diff --git a/i18n/gl.json b/i18n/gl.json index 0122441466..135e3f64cd 100644 --- a/i18n/gl.json +++ b/i18n/gl.json @@ -1613,7 +1613,6 @@ "not_available": "Non dispoÃąible", "not_in_any_album": "Non estÃĄ en ningÃēn ÃĄlbum", "not_selected": "Non seleccionado", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar a Etiqueta de Almacenamento a activos cargados previamente, execute o", "notes": "Notas", "nothing_here_yet": "Aínda nada por aquí", "notification_permission_dialog_content": "Para activar as notificaciÃŗns, vaia a Axustes e seleccione permitir.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "Reasignados {count, plural, one {# activo} other {# activos}} a unha nova persoa", "reassing_hint": "Asignar activos seleccionados a unha persoa existente", "recent": "Recente", - "recent-albums": "Álbums recentes", + "recent_albums": "Álbums recentes", "recent_searches": "Buscas recentes", "recently_added": "Engadido recentemente", "recently_added_page_title": "Engadido Recentemente", diff --git a/i18n/gsw.json b/i18n/gsw.json index 0d8b7abf3a..17f8171c60 100644 --- a/i18n/gsw.json +++ b/i18n/gsw.json @@ -1491,7 +1491,6 @@ "not_available": "N/A", "not_in_any_album": "I keinem Album", "not_selected": "NÃļd usgwählt", - "note_apply_storage_label_to_previously_uploaded assets": "Hiwiis: Zum e Spycherpfad-Bezeichnig aawehde, start de", "notes": "Notize", "nothing_here_yet": "No nÃŧt do", "notification_permission_dialog_content": "Zum Benachrichtige aktiviere, navigier zu Iistellige und drÃŧck \"Erlaube\".", diff --git a/i18n/he.json b/i18n/he.json index 7884cea268..e4d534693b 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -1491,7 +1491,6 @@ "not_available": "לא רלוונטי", "not_in_any_album": "לא בשום אלבום", "not_selected": "לא נבחרו", - "note_apply_storage_label_to_previously_uploaded assets": "ה×ĸרה: כדי להחיל א×Ē ×Ēווי×Ē ×”××—×Ą×•×Ÿ ×ĸל ×Ēמונו×Ē ×Š×”×•×ĸלו ב×ĸבר, הפ×ĸל א×Ē", "notes": "ה×ĸרו×Ē", "nothing_here_yet": "אין כאן כלום ×ĸדיין", "notification_permission_dialog_content": "כדי לאפשר ה×Ēראו×Ē, לך להגדרו×Ē ×”×ž×›×Š×™×¨ ובחר אפ׊ר.", @@ -1687,7 +1686,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {×Ēמונה # הוק×Ļ×Ēה} other {# ×Ēמונו×Ē ×”×•×§×Ļו}} מחדש לאדם חדש", "reassing_hint": "הק×Ļא×Ē ×Ēמונו×Ē ×Š× ×‘×—×¨×• לאדם קיים", "recent": "חדש", - "recent-albums": "אלבומים אחרונים", + "recent_albums": "אלבומים אחרונים", "recent_searches": "חיפושים אחרונים", "recently_added": "× ×•×Ą×Ŗ לאחרונה", "recently_added_page_title": "× ×•×Ą×Ŗ לאחרונה", diff --git a/i18n/hi.json b/i18n/hi.json index 959a3aaf73..ff05291cef 100644 --- a/i18n/hi.json +++ b/i18n/hi.json @@ -56,7 +56,7 @@ "authentication_settings_reenable": "ā¤ĒāĨā¤¨ā¤ƒ ⤏⤕āĨā¤ˇā¤Ž ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤, Server Command ā¤•ā¤ž ā¤ĒāĨā¤°ā¤¯āĨ‹ā¤— ⤕⤰āĨ‡āĨ¤", "background_task_job": "ā¤ĒāĨƒā¤ˇāĨā¤ ā¤­āĨ‚ā¤Žā¤ŋ ā¤•ā¤žā¤°āĨā¤¯", "backup_database": "ā¤ĄāĨ‡ā¤Ÿā¤žā¤ŦāĨ‡ā¤¸ ā¤Ąā¤‚ā¤Ē ā¤Ŧā¤¨ā¤žā¤ā¤‚", - "backup_database_enable_description": "Enable database dumps", + "backup_database_enable_description": "ā¤ĄāĨ‡ā¤Ÿā¤žā¤ŦāĨ‡ā¤¸ ā¤Ąā¤‚ā¤Ē ā¤šā¤žā¤˛āĨ‚ ⤕⤰āĨ‡ā¤‚", "backup_keep_last_amount": "⤰⤖⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤Ēā¤ŋ⤛⤞āĨ‡ ā¤Ąā¤‚ā¤Ē ⤕āĨ€ ā¤Žā¤žā¤¤āĨā¤°ā¤ž", "backup_onboarding_1_description": "⤕āĨā¤˛ā¤žā¤‰ā¤Ą ā¤ŽāĨ‡ā¤‚ ā¤¯ā¤ž ⤕ā¤ŋ⤏āĨ€ ⤅⤍āĨā¤¯ ⤭āĨŒā¤¤ā¤ŋ⤕ ⤏āĨā¤Ĩā¤žā¤¨ ā¤Ē⤰ ⤑ā¤Ģā¤¸ā¤žā¤‡ā¤Ÿ ā¤ĒāĨā¤°ā¤¤ā¤ŋ⤞ā¤ŋā¤Ēā¤ŋāĨ¤", "backup_onboarding_2_description": "ā¤ĩā¤ŋ⤭ā¤ŋ⤍āĨā¤¨ ⤉ā¤Ē⤕⤰⤪āĨ‹ā¤‚ ā¤Ē⤰ ⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ā¤ĒāĨā¤°ā¤¤ā¤ŋā¤¯ā¤žā¤āĨ¤ ā¤‡ā¤¸ā¤ŽāĨ‡ā¤‚ ā¤ŽāĨā¤–āĨā¤¯ ā¤Ģā¤ŧā¤žā¤‡ā¤˛āĨ‡ā¤‚ ⤔⤰ ⤉⤍ ā¤Ģā¤ŧā¤žā¤‡ā¤˛āĨ‹ā¤‚ ā¤•ā¤ž ⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ļā¤žā¤Žā¤ŋ⤞ ā¤šāĨˆāĨ¤", @@ -104,6 +104,8 @@ "image_preview_description": "ā¤ŽāĨ‡ā¤Ÿā¤žā¤ĄāĨ‡ā¤Ÿā¤ž ā¤°ā¤šā¤ŋ⤤ ā¤Žā¤§āĨā¤¯ā¤Ž ā¤†ā¤•ā¤žā¤° ⤕āĨ€ ⤛ā¤ĩā¤ŋ, ⤜ā¤ŋā¤¸ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ā¤ā¤•ā¤˛ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ ā¤ĻāĨ‡ā¤–⤍āĨ‡ ⤔⤰ ā¤Žā¤ļāĨ€ā¤¨ ⤞⤰āĨā¤¨ā¤ŋ⤂⤗ ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤šāĨ‹ā¤¤ā¤ž ā¤šāĨˆ", "image_preview_quality_description": "ā¤ĒāĨ‚⤰āĨā¤ĩā¤žā¤ĩ⤞āĨ‹ā¤•⤍ ⤕āĨ€ ⤗āĨā¤Ŗā¤ĩ⤤āĨā¤¤ā¤ž (1 ⤏āĨ‡ 100 ⤤⤕)āĨ¤ ⤅⤧ā¤ŋ⤕ ā¤Žā¤žā¤¨ ā¤ŦāĨ‡ā¤šā¤¤ā¤° ⤗āĨā¤Ŗā¤ĩ⤤āĨā¤¤ā¤ž ā¤ĻāĨ‡ā¤¤ā¤ž ā¤šāĨˆ, ⤞āĨ‡ā¤•ā¤ŋ⤍ ⤇⤏⤏āĨ‡ ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ā¤•ā¤ž ā¤†ā¤•ā¤žā¤° ā¤Ŧā¤ĸā¤ŧā¤¤ā¤ž ā¤šāĨˆ ⤔⤰ ⤐ā¤Ē ⤕āĨ€ ā¤ĒāĨā¤°ā¤¤ā¤ŋ⤕āĨā¤°ā¤ŋā¤¯ā¤ž ⤕āĨā¤ˇā¤Žā¤¤ā¤ž ā¤•ā¤Ž ā¤šāĨ‹ ⤏⤕⤤āĨ€ ā¤šāĨˆāĨ¤ ā¤Ŧā¤šāĨā¤¤ ā¤•ā¤Ž ā¤Žā¤žā¤¨ ā¤Žā¤ļāĨ€ā¤¨ ⤞⤰āĨā¤¨ā¤ŋ⤂⤗ ⤕āĨ€ ⤗āĨā¤Ŗā¤ĩ⤤āĨā¤¤ā¤ž ⤕āĨ‹ ā¤ĒāĨā¤°ā¤­ā¤žā¤ĩā¤ŋ⤤ ⤕⤰ ā¤¸ā¤•ā¤¤ā¤ž ā¤šāĨˆāĨ¤", "image_preview_title": "ā¤ĒāĨ‚⤰āĨā¤ĩā¤Ļ⤰āĨā¤ļ⤍ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸", + "image_progressive": "ā¤ĒāĨā¤°ā¤—⤤ā¤ŋā¤ļāĨ€ā¤˛", + "image_progressive_description": "JPEG ⤛ā¤ĩā¤ŋ⤝āĨ‹ā¤‚ ⤕āĨ‹ ⤕āĨā¤°ā¤Žā¤ŋ⤕ ⤰āĨ‚ā¤Ē ⤏āĨ‡ ⤞āĨ‹ā¤Ą ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤉⤍āĨā¤šāĨ‡ā¤‚ ā¤ĒāĨā¤°āĨ‹ā¤—āĨā¤°āĨ‡ā¤¸ā¤ŋā¤ĩ⤞āĨ€ ā¤ā¤¨ā¤•āĨ‹ā¤Ą ⤕⤰āĨ‡ā¤‚āĨ¤ ā¤‡ā¤¸ā¤•ā¤ž WebP ⤛ā¤ĩā¤ŋ⤝āĨ‹ā¤‚ ā¤Ē⤰ ⤕āĨ‹ā¤ˆ ā¤ĒāĨā¤°ā¤­ā¤žā¤ĩ ā¤¨ā¤šāĨ€ā¤‚ ā¤Ēā¤Ąā¤ŧā¤¤ā¤ž ā¤šāĨˆāĨ¤", "image_quality": "⤗āĨā¤Ŗā¤ĩ⤤āĨā¤¤ā¤ž", "image_resolution": "⤰ā¤ŋ⤜ā¤ŧāĨ‰ā¤˛āĨā¤¯āĨ‚ā¤ļ⤍", "image_resolution_description": "ā¤‰ā¤šāĨā¤šā¤¤ā¤° ⤰ā¤ŋ⤜ā¤ŧāĨ‰ā¤˛āĨā¤¯āĨ‚ā¤ļ⤍ ⤅⤧ā¤ŋ⤕ ā¤ĩā¤ŋā¤ĩ⤰⤪ ⤏āĨā¤°ā¤•āĨā¤ˇā¤ŋ⤤ ⤰⤖ ā¤¸ā¤•ā¤¤ā¤ž ā¤šāĨˆ, ⤞āĨ‡ā¤•ā¤ŋ⤍ ā¤ā¤¨āĨā¤•āĨ‹ā¤Ą ⤕⤰⤍āĨ‡ ā¤ŽāĨ‡ā¤‚ ⤅⤧ā¤ŋ⤕ ā¤¸ā¤Žā¤¯ ⤞āĨ‡ā¤¤ā¤ž ā¤šāĨˆ, ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ā¤†ā¤•ā¤žā¤° ā¤Ŧā¤Ąā¤ŧā¤ž ā¤šāĨ‹ā¤¤ā¤ž ā¤šāĨˆ ⤔⤰ ⤐ā¤Ē ⤕āĨ€ ā¤ĒāĨā¤°ā¤¤ā¤ŋ⤕āĨā¤°ā¤ŋā¤¯ā¤žā¤ļāĨ€ā¤˛ā¤¤ā¤ž ā¤•ā¤Ž ā¤šāĨ‹ ⤏⤕⤤āĨ€ ā¤šāĨˆāĨ¤", @@ -188,11 +190,23 @@ "machine_learning_smart_search_enabled": "⤏āĨā¤Žā¤žā¤°āĨā¤Ÿ ⤖āĨ‹ā¤œ ⤏⤕āĨā¤ˇā¤Ž ⤕⤰āĨ‡ā¤‚", "machine_learning_smart_search_enabled_description": "⤝ā¤Ļā¤ŋ ⤅⤕āĨā¤ˇā¤Ž ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤šāĨˆ, ⤤āĨ‹ ⤏āĨā¤Žā¤žā¤°āĨā¤Ÿ ⤖āĨ‹ā¤œ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤛ā¤ĩā¤ŋ⤝āĨ‹ā¤‚ ⤕āĨ‹ ā¤ā¤¨āĨā¤•āĨ‹ā¤Ą ā¤¨ā¤šāĨ€ā¤‚ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤žā¤ā¤—ā¤žāĨ¤", "machine_learning_url_description": "ā¤Žā¤ļāĨ€ā¤¨ ⤞⤰āĨā¤¨ā¤ŋ⤂⤗ ⤏⤰āĨā¤ĩ⤰ ā¤•ā¤ž URLāĨ¤ ⤝ā¤Ļā¤ŋ ā¤ā¤• ⤏āĨ‡ ⤅⤧ā¤ŋ⤕ URL ā¤Ļā¤ŋā¤ ā¤—ā¤ ā¤šāĨˆā¤‚, ⤤āĨ‹ ā¤ĒāĨā¤°ā¤¤āĨā¤¯āĨ‡ā¤• ⤏⤰āĨā¤ĩ⤰ ⤕āĨ‹ ā¤ā¤•-ā¤ā¤• ⤕⤰⤕āĨ‡ ⤕āĨ‹ā¤ļā¤ŋā¤ļ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤žā¤ā¤—ā¤ž, ā¤Ēā¤šā¤˛āĨ‡ ⤏āĨ‡ ⤆⤖ā¤ŋ⤰āĨ€ ⤤⤕, ⤜ā¤Ŧ ⤤⤕ ⤕āĨ‹ā¤ˆ ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ‚⤰āĨā¤ĩ⤕ ā¤ĒāĨā¤°ā¤¤ā¤ŋ⤕āĨā¤°ā¤ŋā¤¯ā¤ž ⤍ ā¤ĻāĨ‡āĨ¤ ⤜āĨ‹ ⤏⤰āĨā¤ĩ⤰ ā¤ĒāĨā¤°ā¤¤ā¤ŋ⤕āĨā¤°ā¤ŋā¤¯ā¤ž ā¤¨ā¤šāĨ€ā¤‚ ā¤ĻāĨ‡ā¤¤āĨ‡, ⤉⤍āĨā¤šāĨ‡ā¤‚ ⤅⤏āĨā¤Ĩā¤žā¤¯āĨ€ ⤰āĨ‚ā¤Ē ⤏āĨ‡ ā¤¨ā¤œā¤°ā¤…ā¤‚ā¤Ļā¤žā¤œ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤žā¤ā¤—ā¤ž ⤜ā¤Ŧ ⤤⤕ ā¤ĩāĨ‡ ā¤Ģā¤ŋ⤰ ⤏āĨ‡ ā¤‘ā¤¨ā¤˛ā¤žā¤‡ā¤¨ ⤍ ā¤šāĨ‹ā¤‚āĨ¤", + "maintenance_delete_backup": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤Ąā¤ŋ⤞āĨ€ā¤Ÿ ⤕⤰āĨ‡ā¤‚", + "maintenance_delete_backup_description": "ā¤¯ā¤š ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ⤏āĨā¤Ĩā¤žā¤¯āĨ€ ⤰āĨ‚ā¤Ē ⤏āĨ‡ ā¤Žā¤ŋā¤Ÿā¤ž ā¤ĻāĨ€ ā¤œā¤žā¤ā¤—āĨ€āĨ¤ ⤇⤏āĨ‡ ā¤ĩā¤žā¤Ē⤏ ā¤¨ā¤šāĨ€ā¤‚ ā¤˛ā¤žā¤¯ā¤ž ā¤œā¤ž ⤏⤕āĨ‡ā¤—ā¤žāĨ¤", + "maintenance_delete_error": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤Žā¤ŋā¤Ÿā¤žā¤¯ā¤ž ā¤¨ā¤šāĨ€ā¤‚ ā¤œā¤ž ā¤¸ā¤•ā¤žāĨ¤", + "maintenance_restore_backup": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ĩā¤žā¤Ē⤏ ā¤˛ā¤žā¤ā¤", + "maintenance_restore_backup_description": "Immich ā¤•ā¤ž ā¤¸ā¤žā¤°ā¤ž ā¤ĄāĨ‡ā¤Ÿā¤ž ā¤ĒāĨ‚⤰āĨ€ ā¤¤ā¤°ā¤š ā¤Žā¤ŋā¤Ÿā¤ž ā¤Ļā¤ŋā¤¯ā¤ž ā¤œā¤žā¤ā¤—ā¤ž ⤔⤰ ⤚āĨā¤¨āĨ‡ ā¤—ā¤ ā¤ŦāĨˆā¤•⤅ā¤Ē ⤏āĨ‡ ā¤ĄāĨ‡ā¤Ÿā¤ž ā¤ĩā¤žā¤Ē⤏ ā¤˛ā¤žā¤¯ā¤ž ā¤œā¤žā¤ā¤—ā¤žāĨ¤ ⤆⤗āĨ‡ ā¤Ŧā¤ĸā¤ŧ⤍āĨ‡ ⤏āĨ‡ ā¤Ēā¤šā¤˛āĨ‡ ā¤ā¤• ā¤¨ā¤¯ā¤ž ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤Ŧā¤¨ā¤žā¤¯ā¤ž ā¤œā¤žā¤ā¤—ā¤žāĨ¤", + "maintenance_restore_backup_different_version": "ā¤¯ā¤š ā¤ŦāĨˆā¤•⤅ā¤Ē Immich ⤕āĨ‡ ⤕ā¤ŋ⤏āĨ€ ⤅⤞⤗ version ā¤ŽāĨ‡ā¤‚ ā¤Ŧā¤¨ā¤žā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤Ĩā¤ž!", + "maintenance_restore_backup_unknown_version": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤•ā¤ž version ⤍ā¤ŋ⤰āĨā¤§ā¤žā¤°ā¤ŋ⤤ ā¤¨ā¤šāĨ€ā¤‚ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤žāĨ¤", + "maintenance_restore_database_backup": "ā¤ĄāĨ‡ā¤Ÿā¤žā¤ŦāĨ‡ā¤¸ ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ĩā¤žā¤Ē⤏ ā¤˛ā¤žā¤ā¤", + "maintenance_restore_database_backup_description": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰⤕āĨ‡ ā¤ĄāĨ‡ā¤Ÿā¤žā¤ŦāĨ‡ā¤¸ ⤕āĨ‹ ā¤Ēā¤šā¤˛āĨ‡ ⤕āĨ€ ⤏āĨā¤Ĩā¤ŋ⤤ā¤ŋ ā¤ŽāĨ‡ā¤‚ ā¤ĩā¤žā¤Ē⤏ ā¤˛ā¤žā¤ā¤", "maintenance_settings": "ā¤°ā¤–ā¤°ā¤–ā¤žā¤ĩ", "maintenance_settings_description": "Immich ⤕āĨ‹ ā¤ŽāĨ‡ā¤‚ā¤ŸāĨ‡ā¤¨āĨ‡ā¤‚⤏ ā¤ŽāĨ‹ā¤Ą ā¤ŽāĨ‡ā¤‚ ⤰⤖āĨ‡ā¤‚āĨ¤", - "maintenance_start": "ā¤°ā¤–ā¤°ā¤–ā¤žā¤ĩ ā¤ŽāĨ‹ā¤Ą ā¤ļāĨā¤°āĨ‚ ⤕⤰āĨ‡ā¤‚", + "maintenance_start": "ā¤°ā¤–ā¤°ā¤–ā¤žā¤ĩ ā¤ŽāĨ‹ā¤Ą ā¤Ē⤰ ⤏āĨā¤ĩā¤ŋ⤚ ⤕⤰āĨ‡ā¤‚", "maintenance_start_error": "ā¤ŽāĨ‡ā¤‚ā¤ŸāĨ‡ā¤¨āĨ‡ā¤‚⤏ ā¤ŽāĨ‹ā¤Ą ā¤ļāĨā¤°āĨ‚ ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨ‹ ā¤¸ā¤•ā¤žāĨ¤", + "maintenance_upload_backup": "ā¤ĄāĨ‡ā¤Ÿā¤žā¤ŦāĨ‡ā¤¸ ⤕āĨ€ ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕⤰āĨ‡ā¤‚", + "maintenance_upload_backup_error": "ā¤ŦāĨˆā¤•⤅ā¤Ē ⤅ā¤Ē⤞āĨ‹ā¤Ą ā¤¨ā¤šāĨ€ā¤‚ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤žāĨ¤ ⤕āĨā¤¯ā¤ž ā¤¯ā¤š .sql ā¤¯ā¤ž .sql.gz ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ā¤šāĨˆ?", "manage_concurrency": "ā¤¸ā¤Žā¤ĩ⤰āĨā¤¤āĨ€ā¤¤ā¤ž ā¤ĒāĨā¤°ā¤Ŧ⤂⤧ā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", + "manage_concurrency_description": "ā¤ā¤• ā¤¸ā¤žā¤Ĩ ⤚⤞⤍āĨ‡ ā¤ĩā¤žā¤˛āĨ‡ ⤜āĨ‰ā¤ŦāĨā¤¸ ā¤•ā¤ž ā¤ĒāĨā¤°ā¤Ŧ⤂⤧⤍ ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤜āĨ‰ā¤ŦāĨā¤¸ ā¤ĒāĨ‡ā¤œ ā¤Ē⤰ ā¤œā¤žā¤ā¤", "manage_log_settings": "⤞āĨ‰ā¤— ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗ ā¤ĒāĨā¤°ā¤Ŧ⤂⤧ā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", "map_dark_style": "ā¤Ąā¤žā¤°āĨā¤• ā¤ļāĨˆā¤˛āĨ€", "map_enable_description": "ā¤Žā¤žā¤¨ā¤šā¤ŋ⤤āĨā¤° ⤏āĨā¤ĩā¤ŋā¤§ā¤žā¤ā¤ ⤏⤕āĨā¤ˇā¤Ž ⤕⤰āĨ‡ā¤‚", @@ -258,7 +272,7 @@ "oauth_auto_register": "ā¤‘ā¤ŸāĨ‹ ⤰⤜ā¤ŋ⤏āĨā¤Ÿā¤°", "oauth_auto_register_description": "OAuth ⤕āĨ‡ ā¤¸ā¤žā¤Ĩ ā¤¸ā¤žā¤‡ā¤¨ ⤇⤍ ⤕⤰⤍āĨ‡ ⤕āĨ‡ ā¤Ŧā¤žā¤Ļ ⤏āĨā¤ĩā¤šā¤žā¤˛ā¤ŋ⤤ ⤰āĨ‚ā¤Ē ⤏āĨ‡ ā¤¨ā¤ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤žā¤“⤂ ⤕āĨ‹ ā¤Ēā¤‚ā¤œāĨ€ā¤•āĨƒā¤¤ ⤕⤰āĨ‡ā¤‚", "oauth_button_text": "⤟āĨ‡ā¤•āĨā¤¸āĨā¤Ÿ ā¤Ŧ⤟⤍", - "oauth_client_secret_description": "⤝ā¤Ļā¤ŋ PKCE (⤕āĨ‹ā¤Ą ā¤ā¤•āĨā¤¸ā¤šāĨ‡ā¤‚ā¤œ ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤ĒāĨā¤°āĨ‚ā¤Ģā¤ŧ ⤕āĨā¤‚ā¤œāĨ€) OAuth ā¤ĒāĨā¤°ā¤Ļā¤žā¤¤ā¤ž ā¤ĻāĨā¤ĩā¤žā¤°ā¤ž ā¤¸ā¤Žā¤°āĨā¤Ĩā¤ŋ⤤ ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨˆ ⤤āĨ‹ ā¤¯ā¤š ⤆ā¤ĩā¤ļāĨā¤¯ā¤• ā¤šāĨˆ", + "oauth_client_secret_description": "ā¤¯ā¤š Confidential (⤗āĨ‹ā¤Ē⤍āĨ€ā¤¯) ⤕āĨā¤˛ā¤žā¤‡ā¤‚ā¤Ÿ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤆ā¤ĩā¤ļāĨā¤¯ā¤• ā¤šāĨˆ, ā¤¯ā¤ž ⤝ā¤Ļā¤ŋ Public ⤕āĨā¤˛ā¤žā¤‡ā¤‚ā¤Ÿ ā¤ŽāĨ‡ā¤‚ PKCE (Proof Key for Code Exchange) ā¤¸ā¤Žā¤°āĨā¤Ĩā¤ŋ⤤ ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨˆāĨ¤", "oauth_enable_description": "OAuth ⤏āĨ‡ ⤞āĨ‰ā¤—ā¤ŋ⤍ ⤕⤰āĨ‡ā¤‚", "oauth_mobile_redirect_uri": "ā¤ŽāĨ‹ā¤Ŧā¤žā¤‡ā¤˛ ⤰āĨ€ā¤Ąā¤žā¤¯ā¤°āĨ‡ā¤•āĨā¤Ÿ ⤝āĨ‚ā¤†ā¤°ā¤†ā¤ˆ", "oauth_mobile_redirect_uri_override": "ā¤ŽāĨ‹ā¤Ŧā¤žā¤‡ā¤˛ ⤰āĨ€ā¤Ąā¤žā¤¯ā¤°āĨ‡ā¤•āĨā¤Ÿ ⤝āĨ‚ā¤†ā¤°ā¤†ā¤ˆ ⤓ā¤ĩā¤°ā¤°ā¤žā¤‡ā¤Ą", @@ -282,10 +296,14 @@ "password_settings_description": "ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ⤞āĨ‰ā¤—ā¤ŋ⤍ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗ ā¤ĒāĨā¤°ā¤Ŧ⤂⤧ā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", "paths_validated_successfully": "⤏⤭āĨ€ ā¤Ēā¤Ĩ ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ‚⤰āĨā¤ĩ⤕ ā¤Žā¤žā¤¨āĨā¤¯ ⤕ā¤ŋā¤ ā¤—ā¤", "person_cleanup_job": "ā¤ĩāĨā¤¯ā¤•āĨā¤¤ā¤ŋ ⤏ā¤Ģā¤ŧā¤žā¤ˆ", + "queue_details": "ā¤ĒāĨā¤°ā¤•āĨā¤°ā¤ŋā¤¯ā¤ž ā¤•ā¤¤ā¤žā¤° ā¤•ā¤ž ā¤ĩā¤ŋā¤ĩ⤰⤪", + "queues": "ā¤•ā¤žā¤°āĨā¤¯ ā¤•ā¤¤ā¤žā¤°", + "queues_page_description": "ā¤ĒāĨā¤°ā¤ļā¤žā¤¸ā¤• ā¤•ā¤žā¤°āĨā¤¯ ā¤•ā¤¤ā¤žā¤° ā¤ĒāĨ‡ā¤œ", "quota_size_gib": "⤕āĨ‹ā¤Ÿā¤ž ā¤†ā¤•ā¤žā¤° (GiB)", "refreshing_all_libraries": "⤏⤭āĨ€ ā¤ĒāĨā¤¸āĨā¤¤ā¤•ā¤žā¤˛ā¤¯āĨ‹ā¤‚ ⤕āĨ‹ ā¤¤ā¤žā¤œā¤ŧā¤ž ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤°ā¤šā¤ž ā¤šāĨˆ", - "registration": "ā¤ĩāĨā¤¯ā¤ĩ⤏āĨā¤Ĩā¤žā¤Ē⤕ ā¤Ēā¤‚ā¤œāĨ€ā¤•⤰⤪", + "registration": "ā¤ĒāĨā¤°ā¤ļā¤žā¤¸ā¤• ā¤Ēā¤‚ā¤œāĨ€ā¤•⤰⤪", "registration_description": "⤚āĨ‚⤂⤕ā¤ŋ ⤆ā¤Ē ⤏ā¤ŋ⤏āĨā¤Ÿā¤Ž ā¤Ē⤰ ā¤Ēā¤šā¤˛āĨ‡ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ā¤šāĨˆā¤‚, ⤇⤏⤞ā¤ŋā¤ ⤆ā¤Ē⤕āĨ‹ ā¤ĩāĨā¤¯ā¤ĩ⤏āĨā¤Ĩā¤žā¤Ē⤕ ⤕āĨ‡ ⤰āĨ‚ā¤Ē ā¤ŽāĨ‡ā¤‚ ⤍ā¤ŋ⤝āĨā¤•āĨā¤¤ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤žā¤ā¤—ā¤ž ⤔⤰ ⤆ā¤Ē ā¤ĒāĨā¤°ā¤ļā¤žā¤¸ā¤¨ā¤ŋ⤕ ā¤•ā¤žā¤°āĨā¤¯āĨ‹ā¤‚ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤜ā¤ŋā¤ŽāĨā¤ŽāĨ‡ā¤Ļā¤žā¤° ā¤šāĨ‹ā¤‚⤗āĨ‡, ⤔⤰ ⤅⤤ā¤ŋ⤰ā¤ŋ⤕āĨā¤¤ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤆ā¤Ē⤕āĨ‡ ā¤ĻāĨā¤ĩā¤žā¤°ā¤ž ā¤Ŧā¤¨ā¤žā¤ ā¤œā¤žā¤ā¤‚ā¤—āĨ‡āĨ¤", + "remove_failed_jobs": "⤅⤏ā¤Ģ⤞ ā¤•ā¤žā¤°āĨā¤¯ ā¤šā¤Ÿā¤žā¤ā¤", "require_password_change_on_login": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤕āĨ‹ ā¤Ēā¤šā¤˛āĨ‡ ⤞āĨ‰ā¤—ā¤ŋ⤍ ā¤Ē⤰ ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ŧā¤Ļ⤞⤍āĨ‡ ⤕āĨ€ ⤆ā¤ĩā¤ļāĨā¤¯ā¤•ā¤¤ā¤ž ā¤šāĨˆ", "reset_settings_to_default": "⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸ ⤕āĨ‹ ā¤Ąā¤ŋā¤Ģā¤ŧāĨ‰ā¤˛āĨā¤Ÿ ā¤Ē⤰ ⤰āĨ€ā¤¸āĨ‡ā¤Ÿ ⤕⤰āĨ‡ā¤‚", "reset_settings_to_recent_saved": "⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸ ⤕āĨ‹ ā¤šā¤žā¤˛ ā¤šāĨ€ ā¤ŽāĨ‡ā¤‚ ā¤¸ā¤šāĨ‡ā¤œāĨ€ ā¤—ā¤ˆ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸ ā¤Ē⤰ ⤰āĨ€ā¤¸āĨ‡ā¤Ÿ ⤕⤰āĨ‡ā¤‚", @@ -298,8 +316,10 @@ "server_public_users_description": "ā¤¸ā¤žā¤ā¤ž ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ‡ā¤‚ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤜āĨ‹ā¤Ąā¤ŧ⤤āĨ‡ ā¤¸ā¤Žā¤¯ ⤏⤭āĨ€ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤žā¤“⤂ (ā¤¨ā¤žā¤Ž ⤔⤰ ā¤ˆā¤ŽāĨ‡ā¤˛) ⤕āĨ€ ⤏āĨ‚ā¤šāĨ€ ā¤Ļā¤ŋā¤–ā¤žā¤ˆ ā¤œā¤žā¤¤āĨ€ ā¤šāĨˆāĨ¤ ⤝ā¤Ļā¤ŋ ā¤¯ā¤š ā¤ĩā¤ŋ⤕⤞āĨā¤Ē ⤅⤕āĨā¤ˇā¤Ž ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤šāĨˆ, ⤤āĨ‹ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤏āĨ‚ā¤šāĨ€ ⤕āĨ‡ā¤ĩ⤞ ā¤ĩāĨā¤¯ā¤ĩ⤏āĨā¤Ĩā¤žā¤Ē⤕ (ā¤ā¤Ąā¤Žā¤ŋ⤍) ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤žā¤“⤂ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤉ā¤Ē⤞ā¤ŦāĨā¤§ ā¤šāĨ‹ā¤—āĨ€āĨ¤", "server_settings": "⤏⤰āĨā¤ĩ⤰ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸", "server_settings_description": "⤏⤰āĨā¤ĩ⤰ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸ ā¤ĒāĨā¤°ā¤Ŧ⤂⤧ā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", + "server_stats_page_description": "ā¤ĒāĨā¤°ā¤ļā¤žā¤¸ā¤• (Admin) ⤏⤰āĨā¤ĩ⤰ ā¤†ā¤ā¤•ā¤Ąā¤ŧāĨ‡ ā¤ĒāĨ‡ā¤œ", "server_welcome_message": "⤏āĨā¤ĩā¤žā¤—ā¤¤ ⤏⤂ā¤ĻāĨ‡ā¤ļ", "server_welcome_message_description": "ā¤ā¤• ⤏⤂ā¤ĻāĨ‡ā¤ļ ⤜āĨ‹ ⤞āĨ‰ā¤—ā¤ŋ⤍ ā¤ĒāĨƒā¤ˇāĨā¤  ā¤Ē⤰ ā¤ĒāĨā¤°ā¤Ļ⤰āĨā¤ļā¤ŋ⤤ ā¤šāĨ‹ā¤¤ā¤ž ā¤šāĨˆāĨ¤", + "settings_page_description": "ā¤ĒāĨā¤°ā¤ļā¤žā¤¸ā¤• (Admin) ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸ ā¤ĒāĨ‡ā¤œ", "sidecar_job": "ā¤¸ā¤žā¤‡ā¤Ąā¤•ā¤žā¤° ā¤ŽāĨ‡ā¤Ÿā¤žā¤ĄāĨ‡ā¤Ÿā¤ž", "sidecar_job_description": "ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ⤏ā¤ŋ⤏āĨā¤Ÿā¤Ž ⤏āĨ‡ ā¤¸ā¤žā¤‡ā¤Ąā¤•ā¤žā¤° ā¤ŽāĨ‡ā¤Ÿā¤žā¤ĄāĨ‡ā¤Ÿā¤ž ⤖āĨ‹ā¤œāĨ‡ā¤‚ ā¤¯ā¤ž ⤏ā¤ŋ⤂⤕āĨā¤°ā¤¨ā¤žā¤‡ā¤œā¤ŧ ⤕⤰āĨ‡ā¤‚", "slideshow_duration_description": "ā¤ĒāĨā¤°ā¤¤āĨā¤¯āĨ‡ā¤• ⤛ā¤ĩā¤ŋ ⤕āĨ‹ ā¤ĒāĨā¤°ā¤Ļ⤰āĨā¤ļā¤ŋ⤤ ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤏āĨ‡ā¤•ā¤‚ā¤Ą ⤕āĨ€ ⤏⤂⤖āĨā¤¯ā¤ž", @@ -418,6 +438,8 @@ "user_restore_scheduled_removal": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤕āĨ‹ ā¤ĒāĨā¤¨ā¤°āĨā¤¸āĨā¤Ĩā¤žā¤Ēā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚ - {date, date, long} ā¤Ē⤰ ā¤šā¤Ÿā¤žā¤¯ā¤ž ā¤œā¤žā¤¨ā¤ž ⤍ā¤ŋ⤰āĨā¤§ā¤žā¤°ā¤ŋ⤤ ā¤šāĨˆ", "user_settings": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗", "user_settings_description": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗ ā¤ĒāĨā¤°ā¤Ŧ⤂⤧ā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", + "user_successfully_removed": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž {email} ⤕āĨ‹ ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ‚⤰āĨā¤ĩ⤕ ā¤šā¤Ÿā¤ž ā¤Ļā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤šāĨˆāĨ¤", + "users_page_description": "ā¤ĒāĨā¤°ā¤ļā¤žā¤¸ā¤• (Admin) ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ā¤ĒāĨ‡ā¤œ", "version_check_enabled_description": "⤍⤈ ⤰ā¤ŋ⤞āĨ€ā¤œā¤ŧ ⤕āĨ€ ā¤œā¤žā¤ā¤š ⤕āĨ‡ ⤞ā¤ŋā¤ GitHub ā¤Ē⤰ ⤆ā¤ĩ⤧ā¤ŋ⤕ ⤅⤍āĨā¤°āĨ‹ā¤§ ⤏⤕āĨā¤ˇā¤Ž ⤕⤰āĨ‡ā¤‚", "version_check_implications": "⤏⤂⤏āĨā¤•⤰⤪ ā¤œā¤žā¤ā¤š ⤏āĨā¤ĩā¤ŋā¤§ā¤ž github.com ⤕āĨ‡ ā¤¸ā¤žā¤Ĩ ⤆ā¤ĩ⤧ā¤ŋ⤕ ā¤¸ā¤‚ā¤šā¤žā¤° ā¤Ē⤰ ⤍ā¤ŋ⤰āĨā¤­ā¤° ⤕⤰⤤āĨ€ ā¤šāĨˆ", "version_check_settings": "⤏⤂⤏āĨā¤•⤰⤪ ⤚āĨ‡ā¤•", @@ -429,6 +451,9 @@ "admin_password": "ā¤ĩāĨā¤¯ā¤ĩ⤏āĨā¤Ĩā¤žā¤Ē⤕ ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą", "administration": "ā¤ĒāĨā¤°ā¤ļā¤žā¤¸ā¤¨", "advanced": "ā¤ĩā¤ŋ⤕⤏ā¤ŋ⤤", + "advanced_settings_clear_image_cache": "ā¤‡ā¤ŽāĨ‡ā¤œ ⤕āĨˆā¤ļ (cache) ā¤¸ā¤žā¤Ģā¤ŧ ⤕⤰āĨ‡ā¤‚", + "advanced_settings_clear_image_cache_error": "ā¤‡ā¤ŽāĨ‡ā¤œ ⤕āĨˆā¤ļ (cache) ā¤¸ā¤žā¤Ģā¤ŧ ā¤¨ā¤šāĨ€ā¤‚ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤ž", + "advanced_settings_clear_image_cache_success": "{size} ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ‚⤰āĨā¤ĩ⤕ ā¤¸ā¤žā¤Ģā¤ŧ ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž", "advanced_settings_enable_alternate_media_filter_subtitle": "⤏ā¤ŋ⤂⤕ ⤕āĨ‡ ā¤ĻāĨŒā¤°ā¤žā¤¨ ā¤ĩāĨˆā¤•⤞āĨā¤Ēā¤ŋ⤕ ā¤Žā¤žā¤¨ā¤Ļā¤‚ā¤ĄāĨ‹ā¤‚ ⤕āĨ‡ ā¤†ā¤§ā¤žā¤° ā¤Ē⤰ ā¤ŽāĨ€ā¤Ąā¤ŋā¤¯ā¤ž ⤕āĨ‹ ā¤Ģā¤ŧā¤ŋ⤞āĨā¤Ÿā¤° ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤇⤏ ā¤ĩā¤ŋ⤕⤞āĨā¤Ē ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰āĨ‡ā¤‚āĨ¤ ⤇⤏āĨ‡ ⤕āĨ‡ā¤ĩ⤞ ⤤⤭āĨ€ ā¤†ā¤œā¤ŧā¤Žā¤žā¤ā¤ ⤜ā¤Ŧ ⤆ā¤Ē⤕āĨ‹ ⤐ā¤Ē ā¤ĻāĨā¤ĩā¤žā¤°ā¤ž ⤏⤭āĨ€ ā¤ā¤˛āĨā¤Ŧā¤ŽāĨ‹ā¤‚ ā¤•ā¤ž ā¤Ēā¤¤ā¤ž ā¤˛ā¤—ā¤žā¤¨āĨ‡ ā¤ŽāĨ‡ā¤‚ ā¤¸ā¤Žā¤¸āĨā¤¯ā¤ž ā¤šāĨ‹āĨ¤", "advanced_settings_enable_alternate_media_filter_title": "[ā¤ĒāĨā¤°ā¤¯āĨ‹ā¤—ā¤žā¤¤āĨā¤Žā¤•] ā¤ĩāĨˆā¤•⤞āĨā¤Ēā¤ŋ⤕ ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ā¤ā¤˛āĨā¤Ŧā¤Ž ⤏ā¤ŋ⤂⤕ ā¤Ģā¤ŧā¤ŋ⤞āĨā¤Ÿā¤° ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰āĨ‡ā¤‚", "advanced_settings_log_level_title": "⤞āĨ‰ā¤— ⤏āĨā¤¤ā¤°:{level}", @@ -465,10 +490,12 @@ "album_remove_user": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ā¤šā¤Ÿā¤žā¤ā¤‚?", "album_remove_user_confirmation": "⤕āĨā¤¯ā¤ž ⤆ā¤Ē ā¤ĩā¤žā¤•ā¤ˆ {user} ⤕āĨ‹ ā¤šā¤Ÿā¤žā¤¨ā¤ž ā¤šā¤žā¤šā¤¤āĨ‡ ā¤šāĨˆā¤‚?", "album_search_not_found": "⤆ā¤Ē⤕āĨ€ ⤖āĨ‹ā¤œ ⤏āĨ‡ ā¤ŽāĨ‡ā¤˛ ā¤–ā¤žā¤¤ā¤ž ⤕āĨ‹ā¤ˆ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋā¤˛ā¤ž", + "album_selected": "ā¤ā¤˛āĨā¤Ŧā¤Ž ⤚āĨā¤¨ā¤ž ā¤—ā¤¯ā¤ž", "album_share_no_users": "ā¤ā¤¸ā¤ž ā¤˛ā¤—ā¤¤ā¤ž ā¤šāĨˆ ⤕ā¤ŋ ⤆ā¤Ē⤍āĨ‡ ā¤¯ā¤š ā¤ā¤˛āĨā¤Ŧā¤Ž ⤏⤭āĨ€ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤žā¤“⤂ ⤕āĨ‡ ā¤¸ā¤žā¤Ĩ ā¤¸ā¤žā¤ā¤ž ⤕⤰ ā¤Ļā¤ŋā¤¯ā¤ž ā¤šāĨˆ ā¤¯ā¤ž ⤆ā¤Ē⤕āĨ‡ ā¤Ēā¤žā¤¸ ā¤¸ā¤žā¤ā¤ž ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤕āĨ‹ā¤ˆ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨˆāĨ¤", "album_summary": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤¸ā¤žā¤°ā¤žā¤‚ā¤ļ", "album_updated": "ā¤ā¤˛āĨā¤Ŧā¤Ž ⤅ā¤Ēā¤ĄāĨ‡ā¤Ÿ ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž", "album_updated_setting_description": "⤜ā¤Ŧ ⤕ā¤ŋ⤏āĨ€ ā¤¸ā¤žā¤ā¤ž ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ‡ā¤‚ ⤍⤈ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋā¤¯ā¤žā¤ ā¤šāĨ‹ā¤‚ ⤤āĨ‹ ā¤ā¤• ā¤ˆā¤ŽāĨ‡ā¤˛ ⤏āĨ‚ā¤šā¤¨ā¤ž ā¤ĒāĨā¤°ā¤žā¤ĒāĨā¤¤ ⤕⤰āĨ‡ā¤‚", + "album_upload_assets": "⤅ā¤Ē⤍āĨ‡ ⤕⤂ā¤ĒāĨā¤¯āĨ‚ā¤Ÿā¤° ⤏āĨ‡ ā¤ŽāĨ€ā¤Ąā¤ŋā¤¯ā¤ž ā¤Ģā¤ŧā¤žā¤‡ā¤˛āĨ‡ā¤‚ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕⤰āĨ‡ā¤‚ ⤔⤰ ⤉⤍āĨā¤šāĨ‡ā¤‚ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ‡ā¤‚ ⤜āĨ‹ā¤Ąā¤ŧāĨ‡ā¤‚", "album_user_left": "ā¤Ŧā¤žā¤¯ā¤žā¤ {album}", "album_user_removed": "{user} ⤕āĨ‹ ā¤šā¤Ÿā¤žā¤¯ā¤ž ā¤—ā¤¯ā¤ž", "album_viewer_appbar_delete_confirm": "⤕āĨā¤¯ā¤ž ⤆ā¤Ē ā¤ĩā¤žā¤•ā¤ˆ ⤇⤏ ā¤ā¤˛āĨā¤Ŧā¤Ž ⤕āĨ‹ ⤅ā¤Ē⤍āĨ‡ ā¤–ā¤žā¤¤āĨ‡ ⤏āĨ‡ ā¤šā¤Ÿā¤žā¤¨ā¤ž ā¤šā¤žā¤šā¤¤āĨ‡ ā¤šāĨˆā¤‚?", @@ -481,14 +508,16 @@ "album_viewer_page_share_add_users": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤜āĨ‹ā¤Ąā¤ŧāĨ‡ā¤‚", "album_with_link_access": "⤞ā¤ŋ⤂⤕ ā¤ĩā¤žā¤˛āĨ‡ ⤕ā¤ŋ⤏āĨ€ ⤭āĨ€ ā¤ĩāĨā¤¯ā¤•āĨā¤¤ā¤ŋ ⤕āĨ‹ ⤇⤏ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ‡ā¤‚ ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ ⤔⤰ ⤞āĨ‹ā¤—āĨ‹ā¤‚ ⤕āĨ‹ ā¤ĻāĨ‡ā¤–⤍āĨ‡ ā¤ĻāĨ‡ā¤‚āĨ¤", "albums": "ā¤ā¤˛ā¤Ŧā¤Ž", - "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}", + "albums_count": "{count, plural, one {{count, number} ā¤ā¤˛āĨā¤Ŧā¤Ž} other {{count, number} ā¤ā¤˛āĨā¤Ŧā¤Ž}}", "albums_default_sort_order": "ā¤Ąā¤ŋā¤Ģā¤ŧāĨ‰ā¤˛āĨā¤Ÿ ā¤ā¤˛āĨā¤Ŧā¤Ž ⤏āĨ‰ā¤°āĨā¤Ÿ ⤕āĨā¤°ā¤Ž", "albums_default_sort_order_description": "⤍⤝āĨ‡ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤Ŧā¤¨ā¤žā¤¤āĨ‡ ā¤¸ā¤Žā¤¯ ⤆⤰⤂⤭ā¤ŋ⤕ ā¤Ē⤰ā¤ŋ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ ⤏āĨ‰ā¤°āĨā¤Ÿ ⤕āĨā¤°ā¤ŽāĨ¤", "albums_feature_description": "ā¤Ē⤰ā¤ŋ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ā¤•ā¤ž ⤏⤂⤗āĨā¤°ā¤š ⤜ā¤ŋ⤏āĨ‡ ⤅⤍āĨā¤¯ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤žā¤“⤂ ⤕āĨ‡ ā¤¸ā¤žā¤Ĩ ā¤¸ā¤žā¤ā¤ž ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž ā¤šāĨˆāĨ¤", "albums_on_device_count": "ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ā¤Ē⤰ ā¤ā¤˛āĨā¤Ŧā¤Ž ({count})", + "albums_selected": "{count, plural, one {# ā¤ā¤˛āĨā¤Ŧā¤Ž ⤚āĨā¤¨ā¤ž ā¤—ā¤¯ā¤ž} other {# ā¤ā¤˛āĨā¤Ŧā¤Ž ⤚āĨā¤¨āĨ‡ ā¤—ā¤}}", "all": "⤏⤭āĨ€", "all_albums": "⤏⤭āĨ€ ā¤ā¤˛ā¤Ŧā¤Ž", "all_people": "⤏⤭āĨ€ ⤞āĨ‹ā¤—", + "all_photos": "⤏⤭āĨ€ ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹", "all_videos": "⤏⤭āĨ€ ā¤ĩāĨ€ā¤Ąā¤ŋ⤝āĨ‹", "allow_dark_mode": "ā¤Ąā¤žā¤°āĨā¤• ā¤ŽāĨ‹ā¤Ą ⤕āĨ€ ⤅⤍āĨā¤Žā¤¤ā¤ŋ ā¤ĻāĨ‡ā¤‚", "allow_edits": "⤏⤂ā¤Ēā¤žā¤Ļ⤍ ⤕āĨ€ ⤅⤍āĨā¤Žā¤¤ā¤ŋ ā¤ĻāĨ‡ā¤‚", @@ -496,6 +525,9 @@ "allow_public_user_to_upload": "ā¤¸ā¤žā¤°āĨā¤ĩ⤜⤍ā¤ŋ⤕ ⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ⤕āĨ‹ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕⤰⤍āĨ‡ ⤕āĨ€ ⤅⤍āĨā¤Žā¤¤ā¤ŋ ā¤ĻāĨ‡ā¤‚", "allowed": "⤅⤍āĨā¤Žā¤¤", "alt_text_qr_code": "⤕āĨā¤¯āĨ‚⤆⤰ ⤕āĨ‹ā¤Ą ⤛ā¤ĩā¤ŋ", + "always_keep": "ā¤šā¤ŽāĨ‡ā¤ļā¤ž ⤰⤖āĨ‡ā¤‚", + "always_keep_photos_hint": "“ā¤ĢāĨā¤°āĨ€ ⤅ā¤Ē ⤏āĨā¤ĒāĨ‡ā¤¸â€ ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰⤍āĨ‡ ā¤Ē⤰ ⤇⤏ ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ⤕āĨ€ ⤏⤭āĨ€ ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ ā¤Ŧ⤍āĨ€ ā¤°ā¤šāĨ‡ā¤‚⤗āĨ€āĨ¤", + "always_keep_videos_hint": "“ā¤ĢāĨā¤°āĨ€ ⤅ā¤Ē ⤏āĨā¤ĒāĨ‡ā¤¸â€ ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰⤍āĨ‡ ā¤Ē⤰ ⤇⤏ ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ⤕āĨ€ ⤏⤭āĨ€ ā¤ĩāĨ€ā¤Ąā¤ŋ⤝āĨ‹ ā¤Ŧ⤍āĨ€ ā¤°ā¤šāĨ‡ā¤‚⤗āĨ€āĨ¤", "anti_clockwise": "ā¤ĩā¤žā¤Žā¤žā¤ĩ⤰āĨā¤¤", "api_key": "ā¤ā¤ĒāĨ€ā¤†ā¤ˆ ⤕āĨ€", "api_key_description": "ā¤¯ā¤š ⤕āĨ€ ⤕āĨ‡ā¤ĩ⤞ ā¤ā¤• ā¤Ŧā¤žā¤° ā¤Ļā¤ŋā¤–ā¤žā¤ˆ ā¤œā¤žā¤ā¤—āĨ€āĨ¤ ā¤ĩā¤ŋā¤‚ā¤ĄāĨ‹ ā¤Ŧ⤂ā¤Ļ ⤕⤰⤍āĨ‡ ⤏āĨ‡ ā¤Ēā¤šā¤˛āĨ‡ ⤕āĨƒā¤Ēā¤¯ā¤ž ⤇⤏āĨ‡ ⤕āĨ‰ā¤ĒāĨ€ ā¤•ā¤°ā¤¨ā¤ž ⤏āĨā¤¨ā¤ŋā¤ļāĨā¤šā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚āĨ¤āĨ¤", @@ -522,10 +554,12 @@ "archived_count": "{count, plural, other {# ⤏⤂⤗āĨā¤°ā¤šāĨ€ā¤¤ ⤕ā¤ŋā¤ ā¤—ā¤}}", "are_these_the_same_person": "⤕āĨā¤¯ā¤ž ⤝āĨ‡ ā¤ĩā¤šāĨ€ ā¤ĩāĨā¤¯ā¤•āĨā¤¤ā¤ŋ ā¤šāĨˆā¤‚?", "are_you_sure_to_do_this": "⤕āĨā¤¯ā¤ž ⤆ā¤Ē ā¤ĩā¤žā¤¸āĨā¤¤ā¤ĩ ā¤ŽāĨ‡ā¤‚ ⤇⤏āĨ‡ ā¤•ā¤°ā¤¨ā¤ž ā¤šā¤žā¤šā¤¤āĨ‡ ā¤šāĨˆā¤‚?", + "array_field_not_fully_supported": "Array ā¤Ģā¤ŧāĨ€ā¤˛āĨā¤Ą ⤕āĨ‡ ⤞ā¤ŋā¤ JSON ⤕āĨ‹ ā¤ŽāĨˆā¤¨āĨā¤¯āĨā¤…⤞ ⤰āĨ‚ā¤Ē ⤏āĨ‡ ⤏⤂ā¤Ēā¤žā¤Ļā¤ŋ⤤ ā¤•ā¤°ā¤¨ā¤ž ⤆ā¤ĩā¤ļāĨā¤¯ā¤• ā¤šāĨˆ", "asset_action_delete_err_read_only": "⤕āĨ‡ā¤ĩ⤞ ā¤Ēā¤ĸā¤ŧ⤍āĨ‡ ⤝āĨ‹ā¤—āĨā¤¯ ā¤Ē⤰ā¤ŋ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ(⤓⤂) ⤕āĨ‹ ā¤šā¤Ÿā¤žā¤¯ā¤ž ā¤¨ā¤šāĨ€ā¤‚ ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž, ⤛āĨ‹ā¤Ąā¤ŧā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž ā¤šāĨˆ", "asset_action_share_err_offline": "⤑ā¤Ģā¤ŧā¤˛ā¤žā¤‡ā¤¨ ā¤Ē⤰ā¤ŋ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ(ā¤ā¤) ā¤ĒāĨā¤°ā¤žā¤ĒāĨā¤¤ ā¤¨ā¤šāĨ€ā¤‚ ⤕āĨ€ ā¤œā¤ž ⤏⤕⤤āĨ€, ⤛āĨ‹ā¤Ąā¤ŧāĨ€ ā¤œā¤ž ā¤°ā¤šāĨ€ ā¤šāĨˆ", "asset_added_to_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ‡ā¤‚ ā¤Ąā¤žā¤˛ā¤ž ā¤—ā¤¯ā¤ž", "asset_adding_to_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤ŽāĨ‡ā¤‚ ā¤Ąā¤žā¤˛ā¤ž ā¤œā¤ž ā¤°ā¤šā¤ž ā¤šāĨˆâ€Ļ", + "asset_created": "ā¤ā¤¸āĨ‡ā¤Ÿ ā¤Ŧā¤¨ā¤žā¤¯ā¤ž ā¤—ā¤¯ā¤ž", "asset_description_updated": "⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ ā¤ĩā¤ŋā¤ĩ⤰⤪ ⤅ā¤ĻāĨā¤¯ā¤¤ā¤¨ ⤕⤰ ā¤Ļā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤šāĨˆ", "asset_filename_is_offline": "ā¤ā¤¸āĨ‡ā¤Ÿ {filename} ⤑ā¤Ģā¤ŧā¤˛ā¤žā¤‡ā¤¨ ā¤šāĨˆ", "asset_has_unassigned_faces": "ā¤ā¤¸āĨ‡ā¤Ÿ ā¤ŽāĨ‡ā¤‚ ⤅⤍ā¤ŋ⤰āĨā¤§ā¤žā¤°ā¤ŋ⤤ ⤚āĨ‡ā¤šā¤°āĨ‡ ā¤šāĨˆā¤‚", @@ -538,6 +572,9 @@ "asset_list_layout_sub_title": "⤞āĨ‡ā¤†ā¤‰ā¤Ÿ", "asset_list_settings_subtitle": "ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ ⤗āĨā¤°ā¤ŋā¤Ą ⤞āĨ‡ā¤†ā¤‰ā¤Ÿ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸", "asset_list_settings_title": "⤚ā¤ŋ⤤āĨā¤° ⤕āĨ€ ā¤œā¤žā¤˛āĨ€", + "asset_not_found_on_device_android": "ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ā¤Ē⤰ ā¤ā¤¸āĨ‡ā¤Ÿ ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋā¤˛ā¤ž", + "asset_not_found_on_device_ios": "ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ā¤Ē⤰ ā¤ā¤¸āĨ‡ā¤Ÿ ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋā¤˛ā¤žāĨ¤ ⤝ā¤Ļā¤ŋ ⤆ā¤Ē iCloud ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰ ā¤°ā¤šāĨ‡ ā¤šāĨˆā¤‚, ⤤āĨ‹ iCloud ā¤ŽāĨ‡ā¤‚ ā¤–ā¤°ā¤žā¤Ŧ ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ā¤šāĨ‹ā¤¨āĨ‡ ⤕āĨ‡ ā¤•ā¤žā¤°ā¤Ŗ ā¤ā¤¸āĨ‡ā¤Ÿ ⤤⤕ ā¤Ēā¤šāĨā¤ā¤šā¤ž ā¤¨ā¤šāĨ€ā¤‚ ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž", + "asset_not_found_on_icloud": "iCloud ā¤Ē⤰ ā¤ā¤¸āĨ‡ā¤Ÿ ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋā¤˛ā¤žāĨ¤ iCloud ā¤ŽāĨ‡ā¤‚ ā¤–ā¤°ā¤žā¤Ŧ ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ā¤šāĨ‹ā¤¨āĨ‡ ⤕āĨ‡ ā¤•ā¤žā¤°ā¤Ŗ ā¤ā¤¸āĨ‡ā¤Ÿ ⤤⤕ ā¤Ēā¤šāĨā¤ā¤šā¤ž ā¤¨ā¤šāĨ€ā¤‚ ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž", "asset_offline": "⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ ⤑ā¤Ģā¤ŧā¤˛ā¤žā¤‡ā¤¨", "asset_offline_description": "ā¤¯ā¤š ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ ⤑ā¤Ģā¤ŧā¤˛ā¤žā¤‡ā¤¨ ā¤šāĨˆāĨ¤", "asset_restored_successfully": "⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ(ā¤¯ā¤žā¤) ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ‚⤰āĨā¤ĩ⤕ ā¤ĒāĨā¤¨ā¤°āĨā¤¸āĨā¤Ĩā¤žā¤Ēā¤ŋ⤤ ⤕āĨ€ ā¤—ā¤ˆā¤‚", @@ -589,7 +626,7 @@ "backup_album_selection_page_select_albums": "ā¤ā¤˛āĨā¤Ŧā¤Ž ⤚āĨā¤¨āĨ‡ā¤‚", "backup_album_selection_page_selection_info": "⤚⤝⤍ ā¤œā¤žā¤¨ā¤•ā¤žā¤°āĨ€", "backup_album_selection_page_total_assets": "⤕āĨā¤˛ ⤅ā¤ĻāĨā¤ĩā¤ŋ⤤āĨ€ā¤¯ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋā¤¯ā¤žā¤", - "backup_albums_sync": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤•ā¤ž ⤤āĨā¤˛āĨā¤¯ā¤•ā¤žā¤˛ā¤¨", + "backup_albums_sync": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤•ā¤ž ⤏ā¤ŋ⤂⤕āĨā¤°āĨ‹ā¤¨ā¤žā¤‡ā¤œā¤ŧāĨ‡ā¤ļ⤍", "backup_all": "⤏⤭āĨ€", "backup_background_service_backup_failed_message": "⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ā¤•ā¤ž ā¤ŦāĨˆā¤•⤅ā¤Ē ⤞āĨ‡ā¤¨āĨ‡ ā¤ŽāĨ‡ā¤‚ ā¤ĩā¤ŋā¤Ģ⤞. ā¤ĒāĨā¤¨ā¤ƒ ā¤ĒāĨā¤°ā¤¯ā¤žā¤¸ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤°ā¤šā¤ž ā¤šāĨˆâ€Ļ", "backup_background_service_complete_notification": "ā¤ā¤¸āĨ‡ā¤Ÿ ā¤•ā¤ž ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ĒāĨ‚ā¤°ā¤ž ā¤šāĨā¤†", @@ -650,6 +687,7 @@ "backup_options_page_title": "ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ĩā¤ŋ⤕⤞āĨā¤Ē", "backup_setting_subtitle": "ā¤ĒāĨƒā¤ˇāĨā¤ ā¤­āĨ‚ā¤Žā¤ŋ ⤔⤰ ⤅⤗āĨā¤°ā¤­āĨ‚ā¤Žā¤ŋ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗ ā¤ĒāĨā¤°ā¤Ŧ⤂⤧ā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", "backup_settings_subtitle": "⤅ā¤Ē⤞āĨ‹ā¤Ą ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸ ā¤¸ā¤‚ā¤­ā¤žā¤˛āĨ‡ā¤‚", + "backup_upload_details_page_more_details": "⤅⤧ā¤ŋ⤕ ā¤œā¤žā¤¨ā¤•ā¤žā¤°āĨ€ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤟āĨˆā¤Ē ⤕⤰āĨ‡ā¤‚", "backward": "ā¤Ēā¤ŋā¤›ā¤˛ā¤ž", "biometric_auth_enabled": "ā¤Ŧā¤žā¤¯āĨ‹ā¤ŽāĨ‡ā¤ŸāĨā¤°ā¤ŋ⤕ ā¤ĒāĨā¤°ā¤Žā¤žā¤ŖāĨ€ā¤•⤰⤪ ⤏⤕āĨā¤ˇā¤Ž", "biometric_locked_out": "⤆ā¤Ē ā¤Ŧā¤žā¤¯āĨ‹ā¤ŽāĨ‡ā¤ŸāĨā¤°ā¤ŋ⤕ ā¤ĒāĨā¤°ā¤Žā¤žā¤ŖāĨ€ā¤•⤰⤪ ⤏āĨ‡ ā¤Ŧā¤žā¤šā¤° ā¤šāĨˆā¤‚", @@ -708,6 +746,8 @@ "change_password_form_password_mismatch": "ā¤¸ā¤žā¤‚ā¤•āĨ‡ā¤¤ā¤ŋ⤕ ā¤ļā¤ŦāĨā¤Ļ ā¤ŽāĨ‡ā¤˛ ā¤¨ā¤šāĨ€ā¤‚ ā¤–ā¤žā¤¤āĨ‡", "change_password_form_reenter_new_password": "ā¤¨ā¤¯ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤ĒāĨā¤¨ā¤ƒ ā¤Ļ⤰āĨā¤œ ⤕⤰āĨ‡ā¤‚", "change_pin_code": "ā¤Ēā¤ŋ⤍ ⤕āĨ‹ā¤Ą ā¤Ŧā¤Ļ⤞āĨ‡ā¤‚", + "change_trigger": "⤟āĨā¤°ā¤ŋ⤗⤰ ā¤Ŧā¤Ļ⤞āĨ‡ā¤‚", + "change_trigger_prompt": "⤕āĨā¤¯ā¤ž ⤆ā¤Ē ā¤ĩā¤žā¤•ā¤ˆ ⤟āĨā¤°ā¤ŋ⤗⤰ ā¤Ŧā¤Ļā¤˛ā¤¨ā¤ž ā¤šā¤žā¤šā¤¤āĨ‡ ā¤šāĨˆā¤‚? ⤇⤏⤏āĨ‡ ⤏⤭āĨ€ ā¤ŽāĨŒā¤œāĨ‚ā¤Ļā¤ž ā¤ā¤•āĨā¤ļ⤍ ⤔⤰ ā¤Ģā¤ŧā¤ŋ⤞āĨā¤Ÿā¤° ā¤šā¤Ÿā¤ž ā¤Ļā¤ŋā¤ ā¤œā¤žā¤ā¤ā¤—āĨ‡āĨ¤", "change_your_password": "⤅ā¤Ēā¤¨ā¤ž ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ŧā¤Ļ⤞āĨ‡ā¤‚", "changed_visibility_successfully": "ā¤ĻāĨƒā¤ļāĨā¤¯ā¤¤ā¤ž ⤏ā¤Ģā¤˛ā¤¤ā¤žā¤ĒāĨ‚⤰āĨā¤ĩ⤕ ā¤Ē⤰ā¤ŋā¤ĩ⤰āĨā¤¤ā¤ŋ⤤", "charging": "ā¤šā¤žā¤°āĨā¤œā¤ŋ⤂⤗", @@ -716,8 +756,21 @@ "check_corrupt_asset_backup_button": "ā¤œā¤žā¤ā¤š ⤕⤰āĨ‡ā¤‚", "check_corrupt_asset_backup_description": "ā¤¯ā¤š ā¤œā¤žā¤ā¤š ⤕āĨ‡ā¤ĩ⤞ ā¤ĩā¤žā¤ˆ-ā¤Ģā¤ŧā¤žā¤ˆ ā¤Ē⤰ ā¤šāĨ€ ⤕⤰āĨ‡ā¤‚ ⤔⤰ ⤏⤭āĨ€ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ā¤•ā¤ž ā¤ŦāĨˆā¤•⤅ā¤Ē ⤞āĨ‡ā¤¨āĨ‡ ⤕āĨ‡ ā¤Ŧā¤žā¤Ļ ā¤šāĨ€ ⤕⤰āĨ‡ā¤‚āĨ¤ ⤇⤏ ā¤ĒāĨā¤°ā¤•āĨā¤°ā¤ŋā¤¯ā¤ž ā¤ŽāĨ‡ā¤‚ ⤕āĨā¤› ā¤Žā¤ŋ⤍⤟ ⤞⤗ ⤏⤕⤤āĨ‡ ā¤šāĨˆā¤‚āĨ¤", "check_logs": "⤞āĨ‰ā¤— ā¤œā¤žā¤‚ā¤šāĨ‡ā¤‚", + "checksum": "⤚āĨ‡ā¤•ā¤¸ā¤Ž (checksum)", "choose_matching_people_to_merge": "ā¤Žā¤°āĨā¤œ ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤Žā¤ŋ⤞⤤āĨ‡-⤜āĨā¤˛ā¤¤āĨ‡ ⤞āĨ‹ā¤—āĨ‹ā¤‚ ⤕āĨ‹ ⤚āĨā¤¨āĨ‡ā¤‚", "city": "ā¤ļā¤šā¤°", + "cleanup_confirm_description": "Immich ⤍āĨ‡ {date} ⤏āĨ‡ ā¤Ēā¤šā¤˛āĨ‡ ā¤Ŧā¤¨ā¤žā¤ ā¤—ā¤ {count} ā¤ā¤¸āĨ‡ā¤Ÿ ⤏⤰āĨā¤ĩ⤰ ā¤Ē⤰ ⤏āĨā¤°ā¤•āĨā¤ˇā¤ŋ⤤ ⤰āĨ‚ā¤Ē ⤏āĨ‡ ā¤ŦāĨˆā¤•⤅ā¤Ē ⤕ā¤ŋā¤ ā¤šāĨā¤ ā¤Ēā¤žā¤ ā¤šāĨˆā¤‚āĨ¤ ⤕āĨā¤¯ā¤ž ⤇⤏ ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ⤏āĨ‡ ⤉⤍⤕āĨ€ ⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ā¤ĒāĨā¤°ā¤¤ā¤ŋā¤¯ā¤žā¤ ā¤šā¤Ÿā¤žā¤ˆ ā¤œā¤žā¤ā¤?", + "cleanup_confirm_prompt_title": "⤕āĨā¤¯ā¤ž ⤇⤏ ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ⤏āĨ‡ ā¤šā¤Ÿā¤žā¤ā¤?", + "cleanup_deleted_assets": "ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ⤕āĨ‡ ⤟āĨā¤°āĨˆā¤ļ ā¤ŽāĨ‡ā¤‚ {count} ā¤ā¤¸āĨ‡ā¤Ÿ ⤭āĨ‡ā¤œ ā¤Ļā¤ŋā¤ ā¤—ā¤", + "cleanup_deleting": "⤟āĨā¤°āĨˆā¤ļ ā¤ŽāĨ‡ā¤‚ ⤭āĨ‡ā¤œā¤ž ā¤œā¤ž ā¤°ā¤šā¤ž ā¤šāĨˆâ€Ļ", + "cleanup_found_assets": "{count} ā¤ŦāĨˆā¤•⤅ā¤Ē ⤕ā¤ŋā¤ ā¤—ā¤ ⤐⤏āĨ‡ā¤Ÿ ā¤Žā¤ŋ⤞āĨ‡", + "cleanup_found_assets_with_size": "{count} ā¤ŦāĨˆā¤•⤅ā¤Ē ⤕ā¤ŋā¤ ā¤—ā¤ ⤐⤏āĨ‡ā¤Ÿ ā¤Žā¤ŋ⤞āĨ‡ ({size})", + "cleanup_icloud_shared_albums_excluded": "iCloud ⤕āĨ‡ ā¤ļāĨ‡ā¤¯ā¤° ⤕ā¤ŋā¤ ā¤—ā¤ ā¤ā¤˛āĨā¤Ŧā¤Ž ⤏āĨā¤•āĨˆā¤¨ ā¤ŽāĨ‡ā¤‚ ā¤ļā¤žā¤Žā¤ŋ⤞ ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨˆā¤‚", + "cleanup_no_assets_found": "⤊ā¤Ē⤰ ā¤Ļā¤ŋā¤ ā¤—ā¤ ā¤Žā¤žā¤¨ā¤Ļā¤‚ā¤ĄāĨ‹ā¤‚ ⤏āĨ‡ ā¤ŽāĨ‡ā¤˛ ā¤–ā¤žā¤¨āĨ‡ ā¤ĩā¤žā¤˛āĨ‡ ⤕āĨ‹ā¤ˆ ⤐⤏āĨ‡ā¤Ÿ ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋ⤞āĨ‡āĨ¤ ‘ā¤ĢāĨā¤°āĨ€ ⤉ā¤Ē ⤏āĨā¤ĒāĨ‡ā¤¸â€™ ⤕āĨ‡ā¤ĩ⤞ ⤉⤍āĨā¤šāĨ€ā¤‚ ⤐⤏āĨ‡ā¤Ÿ ⤕āĨ‹ ā¤šā¤Ÿā¤ž ā¤¸ā¤•ā¤¤ā¤ž ā¤šāĨˆ ⤜ā¤ŋā¤¨ā¤•ā¤ž ā¤ŦāĨˆā¤•⤅ā¤Ē ⤏⤰āĨā¤ĩ⤰ ā¤Ē⤰ ⤞ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤šāĨˆ", + "cleanup_preview_title": "ā¤šā¤Ÿā¤žā¤ ā¤œā¤žā¤¨āĨ‡ ā¤ĩā¤žā¤˛āĨ‡ ⤐⤏āĨ‡ā¤Ÿ ({count})", + "cleanup_step3_description": "⤤ā¤ŋā¤Ĩā¤ŋ ⤔⤰ ⤏āĨā¤°ā¤•āĨā¤ˇā¤ŋ⤤ ⤰⤖⤍āĨ‡ ⤕āĨ€ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗ ⤕āĨ‡ ⤅⤍āĨā¤¸ā¤žā¤° ā¤ŦāĨˆā¤•⤅ā¤Ē ⤐⤏āĨ‡ā¤Ÿ ⤏āĨā¤•āĨˆā¤¨ ⤕⤰āĨ‡ā¤‚āĨ¤", + "cleanup_step4_summary": "⤆ā¤Ē⤕āĨ‡ ⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ⤏āĨ‡ ā¤šā¤Ÿā¤žā¤¨āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ {date} ⤏āĨ‡ ā¤Ēā¤šā¤˛āĨ‡ ā¤Ŧā¤¨ā¤žā¤ ā¤—ā¤ {count} ⤐⤏āĨ‡ā¤ŸāĨ¤ ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ Immich ⤐ā¤Ē ā¤ŽāĨ‡ā¤‚ ā¤ĻāĨ‡ā¤–āĨ‡ ā¤œā¤ž ⤏⤕āĨ‡ā¤‚⤗āĨ‡āĨ¤", + "cleanup_trash_hint": "⤏āĨā¤ŸāĨ‹ā¤°āĨ‡ā¤œ ⤏āĨā¤ĒāĨ‡ā¤¸ ā¤ĒāĨ‚⤰āĨ€ ā¤¤ā¤°ā¤š ā¤ĩā¤žā¤Ē⤏ ā¤Ēā¤žā¤¨āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤, ⤏ā¤ŋ⤏āĨā¤Ÿā¤Ž ⤗āĨˆā¤˛ā¤°āĨ€ ⤐ā¤Ē ⤖āĨ‹ā¤˛āĨ‡ā¤‚ ⤔⤰ ⤟āĨā¤°āĨˆā¤ļ ā¤–ā¤žā¤˛āĨ€ ⤕⤰āĨ‡ā¤‚", "clear": "⤏āĨā¤Ē⤎āĨā¤Ÿ", "clear_all": "⤏⤭āĨ€ ā¤¸ā¤žā¤Ģ ⤕⤰āĨ‡ā¤‚", "clear_all_recent_searches": "⤏⤭āĨ€ ā¤šā¤žā¤˛ā¤ŋā¤¯ā¤ž ⤖āĨ‹ā¤œāĨ‡ā¤‚ ā¤¸ā¤žā¤Ģā¤ŧ ⤕⤰āĨ‡ā¤‚", @@ -729,8 +782,10 @@ "client_cert_import": "ā¤†ā¤¯ā¤žā¤¤", "client_cert_import_success_msg": "⤕āĨā¤˛ā¤žā¤‡ā¤‚ā¤Ÿ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° ā¤†ā¤¯ā¤žā¤¤ ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤šāĨˆ", "client_cert_invalid_msg": "ā¤…ā¤Žā¤žā¤¨āĨā¤¯ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° ā¤Ģā¤ŧā¤žā¤‡ā¤˛ ā¤¯ā¤ž ⤗⤞⤤ ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą", + "client_cert_password_message": "⤇⤏ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ļ⤰āĨā¤œ ⤕⤰āĨ‡ā¤‚", + "client_cert_password_title": "ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą", "client_cert_remove_msg": "⤕āĨā¤˛ā¤žā¤‡ā¤‚ā¤Ÿ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° ā¤šā¤Ÿā¤ž ā¤Ļā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž ā¤šāĨˆ", - "client_cert_subtitle": "⤕āĨ‡ā¤ĩ⤞ PKCS12 (.p12, .pfx) ā¤ĒāĨā¤°ā¤žā¤°āĨ‚ā¤Ē ā¤•ā¤ž ā¤¸ā¤Žā¤°āĨā¤Ĩ⤍ ā¤•ā¤°ā¤¤ā¤ž ā¤šāĨˆāĨ¤ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° ā¤†ā¤¯ā¤žā¤¤/⤍ā¤ŋā¤•ā¤žā¤˛ā¤¨ā¤ž ⤕āĨ‡ā¤ĩ⤞ ⤞āĨ‰ā¤—ā¤ŋ⤍ ⤏āĨ‡ ā¤Ēā¤šā¤˛āĨ‡ ā¤šāĨ€ ⤉ā¤Ē⤞ā¤ŦāĨā¤§ ā¤šāĨˆāĨ¤", + "client_cert_subtitle": "⤕āĨ‡ā¤ĩ⤞ PKCS12 (.p12, .pfx) ā¤ĒāĨā¤°ā¤žā¤°āĨ‚ā¤Ē ā¤•ā¤ž ā¤¸ā¤Žā¤°āĨā¤Ĩ⤍ ā¤•ā¤°ā¤¤ā¤ž ā¤šāĨˆāĨ¤ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° ā¤†ā¤¯ā¤žā¤¤/⤍ā¤ŋā¤•ā¤žā¤˛ā¤¨ā¤ž ⤕āĨ‡ā¤ĩ⤞ ⤞āĨ‰ā¤—ā¤ŋ⤍ ⤏āĨ‡ ā¤Ēā¤šā¤˛āĨ‡ ā¤šāĨ€ ⤉ā¤Ē⤞ā¤ŦāĨā¤§ ā¤šāĨˆ", "client_cert_title": "SSL ⤕āĨā¤˛ā¤žā¤‡ā¤‚ā¤Ÿ ā¤ĒāĨā¤°ā¤Žā¤žā¤Ŗā¤Ē⤤āĨā¤° [ā¤ĒāĨā¤°ā¤žā¤¯āĨ‹ā¤—ā¤ŋ⤕]", "clockwise": "ā¤Ļ⤕āĨā¤ˇā¤ŋā¤Ŗā¤žā¤ĩ⤰āĨā¤¤", "close": "ā¤Ŧ⤂ā¤Ļ ⤕⤰āĨ‡ā¤‚", @@ -738,6 +793,7 @@ "collapse_all": "⤏⤭āĨ€ ⤕āĨ‹ ⤏⤂⤕āĨā¤šā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", "color": "⤰⤂⤗", "color_theme": "⤰⤂⤗ ā¤ĨāĨ€ā¤Ž", + "command": "⤆ā¤ĻāĨ‡ā¤ļ", "comment_deleted": "⤟ā¤ŋā¤ĒāĨā¤Ē⤪āĨ€ ā¤šā¤Ÿā¤ž ā¤ĻāĨ€ ā¤—ā¤ˆ", "comment_options": "⤟ā¤ŋā¤ĒāĨā¤Ē⤪āĨ€ ā¤ĩā¤ŋ⤕⤞āĨā¤Ē", "comments_and_likes": "⤟ā¤ŋā¤ĒāĨā¤Ē⤪ā¤ŋā¤¯ā¤žā¤ ⤔⤰ ā¤Ē⤏⤂ā¤Ļ", @@ -782,6 +838,7 @@ "create_album": "ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤Ŧā¤¨ā¤žā¤“", "create_album_page_untitled": "ā¤ļāĨ€ā¤°āĨā¤ˇā¤•ā¤šāĨ€ā¤¨", "create_api_key": "⤐.ā¤ĒāĨ€.ā¤†ā¤ˆ. ā¤šā¤žā¤­āĨ€ ā¤Ŧā¤¨ā¤žā¤ā¤‚", + "create_first_workflow": "ā¤Ēā¤šā¤˛ā¤ž ā¤ĩ⤰āĨā¤•ā¤Ģā¤ŧāĨā¤˛āĨ‹ ā¤Ŧā¤¨ā¤žā¤ā¤‚", "create_library": "ā¤˛ā¤žā¤‡ā¤ŦāĨā¤°āĨ‡ā¤°āĨ€ ā¤Ŧā¤¨ā¤žā¤ā¤‚", "create_link": "⤞ā¤ŋ⤂⤕ ā¤Ŧā¤¨ā¤žā¤ā¤‚", "create_link_to_share": "ā¤ļāĨ‡ā¤¯ā¤° ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤞ā¤ŋ⤂⤕ ā¤Ŧā¤¨ā¤žā¤ā¤‚", @@ -796,14 +853,18 @@ "create_tag": "⤟āĨˆā¤— ā¤Ŧā¤¨ā¤žā¤ā¤", "create_tag_description": "ā¤ā¤• ā¤¨ā¤¯ā¤ž ⤟āĨˆā¤— ā¤Ŧā¤¨ā¤žā¤ā¤āĨ¤ ⤍āĨ‡ā¤¸āĨā¤ŸāĨ‡ā¤Ą ⤟āĨˆā¤— ⤕āĨ‡ ⤞ā¤ŋā¤, ⤕āĨƒā¤Ēā¤¯ā¤ž ā¤Ģā¤ŧāĨ‰ā¤°ā¤ĩ⤰āĨā¤Ą ⤏āĨā¤˛āĨˆā¤ļ ā¤¸ā¤šā¤ŋ⤤ ⤟āĨˆā¤— ā¤•ā¤ž ā¤ĒāĨ‚ā¤°ā¤ž ā¤Ēā¤Ĩ ā¤Ļ⤰āĨā¤œ ⤕⤰āĨ‡ā¤‚āĨ¤", "create_user": "⤉ā¤Ē⤝āĨ‹ā¤—⤕⤰āĨā¤¤ā¤ž ā¤Ŧā¤¨ā¤žā¤‡ā¤¯āĨ‡", + "create_workflow": "ā¤ĩ⤰āĨā¤•ā¤Ģā¤ŧāĨā¤˛āĨ‹ ā¤Ŧā¤¨ā¤žā¤ā¤‚", "created": "ā¤Ŧā¤¨ā¤žā¤¯ā¤ž", "created_at": "ā¤Ŧā¤¨ā¤žā¤¯ā¤ž ā¤Ĩā¤ž", "creating_linked_albums": "⤜āĨāĨœāĨ‡ ā¤šāĨā¤ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤Ŧā¤¨ā¤žā¤ ā¤œā¤ž ā¤°ā¤šāĨ‡ ā¤šāĨˆā¤‚..āĨ¤", "crop": "ā¤›ā¤žā¤ā¤ŸāĨ‡ā¤‚", + "crop_aspect_ratio_free": "⤏āĨā¤ĩ⤤⤂⤤āĨā¤°", + "crop_aspect_ratio_original": "ā¤ŽāĨ‚⤞ ⤅⤍āĨā¤Ēā¤žā¤¤", "curated_object_page_title": "⤚āĨ€ā¤œā¤ŧāĨ‡ā¤‚", "current_device": "ā¤ĩ⤰āĨā¤¤ā¤Žā¤žā¤¨ ⤉ā¤Ē⤕⤰⤪", "current_pin_code": "ā¤ĩ⤰āĨā¤¤ā¤Žā¤žā¤¨ ā¤Ēā¤ŋ⤍ ⤕āĨ‹ā¤Ą", "current_server_address": "ā¤ĩ⤰āĨā¤¤ā¤Žā¤žā¤¨ ⤏⤰āĨā¤ĩ⤰ ā¤Ēā¤¤ā¤ž", + "custom_date": "ā¤Žā¤¨ā¤šā¤žā¤šāĨ€ ⤤ā¤ŋā¤Ĩā¤ŋ", "custom_locale": "⤕⤏āĨā¤Ÿā¤Ž ⤞āĨ‹ā¤•āĨ‡ā¤˛", "custom_locale_description": "ā¤­ā¤žā¤ˇā¤ž ⤔⤰ ⤕āĨā¤ˇāĨ‡ā¤¤āĨā¤° ⤕āĨ‡ ā¤†ā¤§ā¤žā¤° ā¤Ē⤰ ā¤Ļā¤ŋā¤¨ā¤žā¤‚ā¤• ⤔⤰ ⤏⤂⤖āĨā¤¯ā¤žā¤ā¤ ā¤ĒāĨā¤°ā¤žā¤°āĨ‚ā¤Ēā¤ŋ⤤ ⤕⤰āĨ‡ā¤‚", "custom_url": "⤕⤏āĨā¤Ÿā¤Ž URL", @@ -1178,7 +1239,7 @@ "home_page_delete_remote_err_local": "ā¤ĻāĨ‚⤰⤏āĨā¤Ĩ ⤚⤝⤍ ⤕āĨ‹ ā¤šā¤Ÿā¤žā¤¨āĨ‡, ⤛āĨ‹ā¤Ąā¤ŧ⤍āĨ‡ ā¤ŽāĨ‡ā¤‚ ⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋā¤¯ā¤žā¤", "home_page_favorite_err_local": "⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ⤕āĨ‹ ⤅⤭āĨ€ ⤤⤕ ā¤Ē⤏⤂ā¤ĻāĨ€ā¤Ļā¤ž ā¤¨ā¤šāĨ€ā¤‚ ā¤Ŧā¤¨ā¤žā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤ž, ⤛āĨ‹ā¤Ąā¤ŧā¤ž ā¤œā¤ž ā¤°ā¤šā¤ž ā¤šāĨˆ", "home_page_favorite_err_partner": "⤅ā¤Ŧ ⤤⤕ ā¤Ēā¤žā¤°āĨā¤Ÿā¤¨ā¤° ā¤ā¤¸āĨ‡ā¤ŸāĨā¤¸ ⤕āĨ‹ ā¤ĢāĨ‡ā¤ĩ⤰āĨ‡ā¤Ÿ ā¤¨ā¤šāĨ€ā¤‚ ⤕⤰ ⤏⤕⤤āĨ‡, ⤏āĨā¤•ā¤ŋā¤Ē ⤕⤰ ā¤°ā¤šāĨ‡ ā¤šāĨˆā¤‚", - "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "home_page_first_time_notice": "⤝ā¤Ļā¤ŋ ⤆ā¤Ē ā¤Ēā¤šā¤˛āĨ€ ā¤Ŧā¤žā¤° ⤇⤏ ⤐ā¤Ē ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰ ā¤°ā¤šāĨ‡ ā¤šāĨˆā¤‚, ⤤āĨ‹ ⤕āĨƒā¤Ēā¤¯ā¤ž ā¤ā¤• ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ā¤˛āĨā¤Ŧā¤Ž ⤚āĨā¤¨āĨ‡ā¤‚, ā¤¤ā¤žā¤•ā¤ŋ ā¤Ÿā¤žā¤‡ā¤Žā¤˛ā¤žā¤‡ā¤¨ ā¤ŽāĨ‡ā¤‚ ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ ⤔⤰ ā¤ĩāĨ€ā¤Ąā¤ŋ⤝āĨ‹ ā¤Ļā¤ŋā¤–ā¤žā¤ˆ ā¤ĻāĨ‡ ⤏⤕āĨ‡ā¤‚", "home_page_locked_error_local": "⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ⤕āĨ‹ ⤞āĨ‰ā¤• ⤕ā¤ŋā¤ ā¤—ā¤ ā¤Ģā¤ŧāĨ‹ā¤˛āĨā¤Ąā¤° ā¤ŽāĨ‡ā¤‚ ā¤¨ā¤šāĨ€ā¤‚ ⤞āĨ‡ ā¤œā¤žā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž, ⤛āĨ‹ā¤Ąā¤ŧā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž ā¤šāĨˆ", "home_page_locked_error_partner": "ā¤¸ā¤žā¤āĨ‡ā¤Ļā¤žā¤° ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ⤕āĨ‹ ⤞āĨ‰ā¤• ⤕ā¤ŋā¤ ā¤—ā¤ ā¤Ģā¤ŧāĨ‹ā¤˛āĨā¤Ąā¤° ā¤ŽāĨ‡ā¤‚ ā¤¨ā¤šāĨ€ā¤‚ ⤞āĨ‡ ā¤œā¤žā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤¤ā¤ž, ⤛āĨ‹ā¤Ąā¤ŧāĨ‡ā¤‚", "home_page_share_err_local": "⤞āĨ‹ā¤•⤞ ā¤ā¤¸āĨ‡ā¤ŸāĨā¤¸ ⤕āĨ‹ ⤞ā¤ŋ⤂⤕ ⤕āĨ‡ ⤜⤰ā¤ŋā¤ ā¤ļāĨ‡ā¤¯ā¤° ā¤¨ā¤šāĨ€ā¤‚ ⤕⤰ ⤏⤕⤤āĨ‡, ⤏āĨā¤•ā¤ŋā¤Ē ⤕⤰ ā¤°ā¤šāĨ‡ ā¤šāĨˆā¤‚", @@ -1447,7 +1508,7 @@ "no_albums_with_name_yet": "ā¤ā¤¸ā¤ž ā¤˛ā¤—ā¤¤ā¤ž ā¤šāĨˆ ⤕ā¤ŋ ⤆ā¤Ē⤕āĨ‡ ā¤Ēā¤žā¤¸ ⤅⤭āĨ€ ⤤⤕ ⤇⤏ ā¤¨ā¤žā¤Ž ā¤•ā¤ž ⤕āĨ‹ā¤ˆ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨˆāĨ¤", "no_albums_yet": "ā¤ā¤¸ā¤ž ā¤˛ā¤—ā¤¤ā¤ž ā¤šāĨˆ ⤕ā¤ŋ ⤆ā¤Ē⤕āĨ‡ ā¤Ēā¤žā¤¸ ⤅⤭āĨ€ ⤤⤕ ⤕āĨ‹ā¤ˆ ā¤ā¤˛āĨā¤Ŧā¤Ž ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨˆāĨ¤", "no_archived_assets_message": "ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ ⤔⤰ ā¤ĩāĨ€ā¤Ąā¤ŋ⤝āĨ‹ ⤕āĨ‹ ⤅ā¤Ē⤍āĨ‡ ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ ā¤ĻāĨƒā¤ļāĨā¤¯ ⤏āĨ‡ ⤛ā¤ŋā¤Ēā¤žā¤¨āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤉⤍āĨā¤šāĨ‡ā¤‚ ⤏⤂⤗āĨā¤°ā¤šāĨ€ā¤¤ ⤕⤰āĨ‡ā¤‚", - "no_assets_message": "⤅ā¤Ēā¤¨ā¤ž ā¤Ēā¤šā¤˛ā¤ž ā¤ĢāĨ‹ā¤ŸāĨ‹ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤕āĨā¤˛ā¤ŋ⤕ ⤕⤰āĨ‡ā¤‚", + "no_assets_message": "⤅ā¤Ē⤍āĨ€ ā¤Ēā¤šā¤˛āĨ€ ā¤Ģā¤ŧāĨ‹ā¤ŸāĨ‹ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤕āĨā¤˛ā¤ŋ⤕ ⤕⤰āĨ‡ā¤‚", "no_assets_to_show": "ā¤Ļā¤ŋā¤–ā¤žā¤¨āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤕āĨ‹ā¤ˆ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ ā¤¨ā¤šāĨ€ā¤‚", "no_cast_devices_found": "⤕āĨ‹ā¤ˆ ā¤•ā¤žā¤¸āĨā¤Ÿ ā¤Ąā¤ŋā¤ĩā¤žā¤‡ā¤¸ ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋā¤˛ā¤ž", "no_checksum_local": "⤕āĨ‹ā¤ˆ ⤚āĨ‡ā¤•ā¤¸ā¤Ž ⤉ā¤Ē⤞ā¤ŦāĨā¤§ ā¤¨ā¤šāĨ€ā¤‚ ā¤šāĨˆ - ⤏āĨā¤Ĩā¤žā¤¨āĨ€ā¤¯ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋā¤¯ā¤žā¤‚ ā¤ĒāĨā¤°ā¤žā¤ĒāĨā¤¤ ā¤¨ā¤šāĨ€ā¤‚ ⤕āĨ€ ā¤œā¤ž ⤏⤕⤤āĨ€ā¤‚", @@ -1474,7 +1535,6 @@ "not_available": "ā¤˛ā¤žā¤—āĨ‚ ā¤¨ā¤šāĨ€ā¤‚", "not_in_any_album": "⤕ā¤ŋ⤏āĨ€ ā¤ā¤˛ā¤Ŧā¤Ž ā¤ŽāĨ‡ā¤‚ ā¤¨ā¤šāĨ€ā¤‚", "not_selected": "⤚⤝⤍ā¤ŋ⤤ ā¤¨ā¤šāĨ€ā¤‚", - "note_apply_storage_label_to_previously_uploaded assets": "⤍āĨ‹ā¤Ÿ: ā¤Ēā¤šā¤˛āĨ‡ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕āĨ€ ā¤—ā¤ˆ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ā¤Ē⤰ ⤏āĨā¤ŸāĨ‹ā¤°āĨ‡ā¤œ ⤞āĨ‡ā¤Ŧ⤞ ā¤˛ā¤žā¤—āĨ‚ ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤, ā¤šā¤˛ā¤žā¤ā¤", "notes": "⤟ā¤ŋā¤ĒāĨā¤Ē⤪ā¤ŋā¤¯ā¤žā¤", "nothing_here_yet": "ā¤¯ā¤šā¤žā¤ ⤅⤭āĨ€ ⤤⤕ ⤕āĨā¤› ā¤¨ā¤šāĨ€ā¤‚", "notification_permission_dialog_content": "⤏āĨ‚ā¤šā¤¨ā¤žā¤ā¤‚ ⤏⤕āĨā¤ˇā¤Ž ⤕⤰⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤¸ ā¤ŽāĨ‡ā¤‚ ā¤œā¤žā¤ā¤‚ ⤔⤰ ⤅⤍āĨā¤Žā¤¤ā¤ŋ ā¤ĻāĨ‡ā¤‚ ⤚āĨā¤¨āĨ‡ā¤‚āĨ¤", @@ -1665,11 +1725,11 @@ "readonly_mode_enabled": "⤕āĨ‡ā¤ĩ⤞-ā¤Ēā¤ĸā¤ŧ⤍āĨ‡ ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤ŽāĨ‹ā¤Ą ⤏⤕āĨā¤ˇā¤Ž", "ready_for_upload": "⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕āĨ‡ ⤞ā¤ŋā¤ ⤤āĨˆā¤¯ā¤žā¤°", "reassign": "ā¤ĒāĨā¤¨ā¤ƒ ā¤…ā¤¸ā¤žā¤‡ā¤¨", - "reassigned_assets_to_existing_person": "{count, plural, one {# asset} other {# assets}} ⤕āĨ‹ {name, select, null {an existing person} other {{name}}}⤕āĨ‹ ā¤Ģā¤ŋ⤰ ⤏āĨ‡ ā¤…ā¤¸ā¤žā¤‡ā¤¨ ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž", + "reassigned_assets_to_existing_person": "{count, plural, one {# asset} other {# assets}} ⤕āĨ‹ {name, select, null {an existing person} other {{name}}}⤕āĨ‹ ā¤Ģā¤ŋ⤰ ⤏āĨ‡ ā¤…ā¤¸ā¤žā¤‡ā¤¨ ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž", "reassigned_assets_to_new_person": "{count, plural, one {# asset} other {# assets}} ⤕āĨ‹ ā¤ā¤• ā¤¨ā¤ ā¤ĩāĨā¤¯ā¤•āĨā¤¤ā¤ŋ ⤕āĨ‹ ā¤Ģā¤ŋ⤰ ⤏āĨ‡ ā¤…ā¤¸ā¤žā¤‡ā¤¨ ⤕ā¤ŋā¤¯ā¤ž ā¤—ā¤¯ā¤ž", "reassing_hint": "⤚⤝⤍ā¤ŋ⤤ ⤏⤂ā¤Ē⤤āĨā¤¤ā¤ŋ⤝āĨ‹ā¤‚ ⤕āĨ‹ ⤕ā¤ŋ⤏āĨ€ ā¤ŽāĨŒā¤œāĨ‚ā¤Ļā¤ž ā¤ĩāĨā¤¯ā¤•āĨā¤¤ā¤ŋ ⤕āĨ‹ ⤏āĨŒā¤‚ā¤ĒāĨ‡ā¤‚", "recent": "ā¤šā¤žā¤˛ ā¤šāĨ€ ā¤•ā¤ž", - "recent-albums": "ā¤šā¤žā¤˛ ⤕āĨ‡ ā¤ā¤˛āĨā¤Ŧā¤Ž", + "recent_albums": "ā¤šā¤žā¤˛ ⤕āĨ‡ ā¤ā¤˛āĨā¤Ŧā¤Ž", "recent_searches": "ā¤šā¤žā¤˛ ⤕āĨ€ ⤖āĨ‹ā¤œāĨ‡ā¤‚", "recently_added": "ā¤šā¤žā¤˛ ā¤šāĨ€ ā¤ŽāĨ‡ā¤‚ ā¤Ąā¤žā¤˛ā¤ž ā¤—ā¤¯ā¤ž", "recently_added_page_title": "ā¤šā¤žā¤˛ ā¤šāĨ€ ā¤ŽāĨ‡ā¤‚ ā¤Ąā¤žā¤˛ā¤ž ā¤—ā¤¯ā¤ž", @@ -1874,7 +1934,7 @@ "setting_notifications_notify_failures_grace_period": "ā¤ŦāĨˆā¤•⤗āĨā¤°ā¤žā¤‰ā¤‚ā¤Ą ā¤ŦāĨˆā¤•⤅ā¤Ē ā¤ĢāĨ‡ā¤˛ā¤ŋ⤝⤰ ⤕āĨ€ ⤏āĨ‚ā¤šā¤¨ā¤ž ā¤ĻāĨ‡ā¤‚: {duration}", "setting_notifications_notify_hours": "{count} ā¤˜ā¤‚ā¤ŸāĨ‡", "setting_notifications_notify_immediately": "⤤āĨā¤°ā¤‚⤤", - "setting_notifications_notify_minutes": "{count} ā¤Žā¤ŋ⤍⤟", + "setting_notifications_notify_minutes": "{count} ā¤Žā¤ŋ⤍⤟", "setting_notifications_notify_never": "⤕⤭āĨ€ ā¤¨ā¤šāĨ€ā¤‚", "setting_notifications_notify_seconds": "{count} ⤏āĨ‡ā¤•ā¤‚ā¤Ą", "setting_notifications_single_progress_subtitle": "ā¤šā¤° ā¤ā¤¸āĨ‡ā¤Ÿ ⤕āĨ‡ ⤞ā¤ŋā¤ ⤅ā¤Ē⤞āĨ‹ā¤Ą ā¤ĒāĨā¤°āĨ‹ā¤—āĨā¤°āĨ‡ā¤¸ ⤕āĨ€ ā¤ĒāĨ‚⤰āĨ€ ā¤œā¤žā¤¨ā¤•ā¤žā¤°āĨ€", @@ -1917,7 +1977,7 @@ "shared_link_custom_url_description": "⤕⤏āĨā¤Ÿā¤Ž URL ⤏āĨ‡ ⤇⤏ ā¤ļāĨ‡ā¤¯ā¤°āĨā¤Ą ⤞ā¤ŋ⤂⤕ ⤕āĨ‹ ā¤ā¤•āĨā¤¸āĨ‡ā¤¸ ⤕⤰āĨ‡ā¤‚", "shared_link_edit_description_hint": "ā¤ļāĨ‡ā¤¯ā¤° ā¤ĩā¤ŋā¤ĩ⤰⤪ ā¤Ļ⤰āĨā¤œ ⤕⤰āĨ‡ā¤‚", "shared_link_edit_expire_after_option_day": "1 ā¤Ļā¤ŋ⤍", - "shared_link_edit_expire_after_option_days": "{count} ā¤Ļā¤ŋ⤍", + "shared_link_edit_expire_after_option_days": "{count} ā¤Ļā¤ŋ⤍", "shared_link_edit_expire_after_option_hour": "1 ā¤˜ā¤‚ā¤Ÿā¤ž", "shared_link_edit_expire_after_option_hours": "{count} ā¤˜ā¤‚ā¤ŸāĨ‡", "shared_link_edit_expire_after_option_minute": "1 ā¤Žā¤ŋ⤍⤟", @@ -1926,8 +1986,8 @@ "shared_link_edit_expire_after_option_year": "{count} ā¤ĩ⤰āĨā¤ˇ", "shared_link_edit_password_hint": "ā¤ļāĨ‡ā¤¯ā¤° ā¤Ēā¤žā¤¸ā¤ĩ⤰āĨā¤Ą ā¤Ļ⤰āĨā¤œ ⤕⤰āĨ‡ā¤‚", "shared_link_edit_submit_button": "⤅ā¤Ēā¤ĄāĨ‡ā¤Ÿ ⤞ā¤ŋ⤂⤕", - "shared_link_error_server_url_fetch": "⤏⤰āĨā¤ĩ⤰ URL ā¤¨ā¤šāĨ€ā¤‚ ā¤Žā¤ŋ⤞ ā¤°ā¤šā¤ž ā¤šāĨˆ", - "shared_link_expires_day": "{count} ā¤Ļā¤ŋ⤍ ā¤ŽāĨ‡ā¤‚ ā¤¸ā¤Žā¤žā¤ĒāĨā¤¤ ā¤šāĨ‹ ā¤°ā¤šā¤ž ā¤šāĨˆ", + "shared_link_error_server_url_fetch": "⤏⤰āĨā¤ĩ⤰ URL ā¤ĒāĨā¤°ā¤žā¤ĒāĨā¤¤ ā¤¨ā¤šāĨ€ā¤‚ ⤕ā¤ŋā¤¯ā¤ž ā¤œā¤ž ā¤¸ā¤•ā¤ž", + "shared_link_expires_day": "{count} ā¤Ļā¤ŋ⤍ ā¤ŽāĨ‡ā¤‚ ⤇⤏⤕āĨ€ ā¤ĩāĨˆā¤§ā¤¤ā¤ž ā¤¸ā¤Žā¤žā¤ĒāĨā¤¤ ā¤šāĨ‹ ā¤œā¤žā¤ā¤—āĨ€", "shared_link_expires_days": "{count} ā¤Ļā¤ŋ⤍āĨ‹ā¤‚ ā¤ŽāĨ‡ā¤‚ ā¤¸ā¤Žā¤žā¤ĒāĨā¤¤ ā¤šāĨ‹ ā¤œā¤žā¤ā¤—ā¤ž", "shared_link_expires_hour": "{count} ā¤˜ā¤‚ā¤ŸāĨ‡ ā¤ŽāĨ‡ā¤‚ ā¤¸ā¤Žā¤žā¤ĒāĨā¤¤ ā¤šāĨ‹ ā¤œā¤žā¤ā¤—ā¤ž", "shared_link_expires_hours": "{count} ā¤˜ā¤‚ā¤ŸāĨ‡ ā¤ŽāĨ‡ā¤‚ ā¤¸ā¤Žā¤žā¤ĒāĨā¤¤ ā¤šāĨ‹ ā¤œā¤žā¤ā¤—ā¤ž", @@ -2052,11 +2112,11 @@ "theme_selection_description": "⤆ā¤Ē⤕āĨ‡ ā¤ŦāĨā¤°ā¤žā¤‰ā¤œā¤ŧ⤰ ⤕āĨ€ ⤏ā¤ŋ⤏āĨā¤Ÿā¤Ž ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋā¤•ā¤¤ā¤ž ⤕āĨ‡ ā¤†ā¤§ā¤žā¤° ā¤Ē⤰ ā¤ĨāĨ€ā¤Ž ⤕āĨ‹ ⤏āĨā¤ĩā¤šā¤žā¤˛ā¤ŋ⤤ ⤰āĨ‚ā¤Ē ⤏āĨ‡ ā¤ĒāĨā¤°ā¤•ā¤žā¤ļ ā¤¯ā¤ž ⤅⤂⤧āĨ‡ā¤°āĨ‡ ā¤Ē⤰ ⤏āĨ‡ā¤Ÿ ⤕⤰āĨ‡ā¤‚", "theme_setting_asset_list_storage_indicator_title": "ā¤ā¤¸āĨ‡ā¤Ÿ ā¤Ÿā¤žā¤‡ā¤˛āĨā¤¸ ā¤Ē⤰ ⤏āĨā¤ŸāĨ‹ā¤°āĨ‡ā¤œ ā¤‡ā¤‚ā¤Ąā¤ŋ⤕āĨ‡ā¤Ÿā¤° ā¤Ļā¤ŋā¤–ā¤žā¤ā¤‚", "theme_setting_asset_list_tiles_per_row_title": "ā¤ĒāĨā¤°ā¤¤ā¤ŋ ā¤Ē⤂⤕āĨā¤¤ā¤ŋ ā¤ā¤¸āĨ‡ā¤Ÿ ⤕āĨ€ ⤏⤂⤖āĨā¤¯ā¤ž ({count})", - "theme_setting_colorful_interface_subtitle": "ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋ⤕ ⤰⤂⤗ ⤕āĨ‹ ā¤ĒāĨƒā¤ˇāĨā¤ ā¤­āĨ‚ā¤Žā¤ŋ ā¤¸ā¤¤ā¤šāĨ‹ā¤‚ ā¤Ē⤰ ā¤˛ā¤žā¤—āĨ‚ ⤕⤰āĨ‡ā¤‚", + "theme_setting_colorful_interface_subtitle": "ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋ⤕ ⤰⤂⤗ ⤕āĨ‹ ā¤ĒāĨƒā¤ˇāĨā¤ ā¤­āĨ‚ā¤Žā¤ŋ ā¤¸ā¤¤ā¤šāĨ‹ā¤‚ ā¤Ē⤰ ā¤˛ā¤žā¤—āĨ‚ ⤕⤰āĨ‡ā¤‚āĨ¤", "theme_setting_colorful_interface_title": "⤰⤂⤗āĨ€ā¤¨ ā¤‡ā¤‚ā¤Ÿā¤°ā¤Ģā¤ŧāĨ‡ā¤¸", "theme_setting_image_viewer_quality_subtitle": "ā¤Ąā¤ŋ⤟āĨ‡ā¤˛ ā¤‡ā¤ŽāĨ‡ā¤œ ā¤ĩāĨā¤¯āĨ‚⤅⤰ ⤕āĨ€ ⤕āĨā¤ĩā¤žā¤˛ā¤ŋ⤟āĨ€ ā¤ā¤Ąā¤œā¤¸āĨā¤Ÿ ⤕⤰āĨ‡ā¤‚", "theme_setting_image_viewer_quality_title": "⤛ā¤ĩā¤ŋ ā¤Ļ⤰āĨā¤ļ⤕ ⤗āĨā¤Ŗā¤ĩ⤤āĨā¤¤ā¤ž", - "theme_setting_primary_color_subtitle": "ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋ⤕ ⤕āĨā¤°ā¤ŋā¤¯ā¤žā¤“ā¤‚ ⤔⤰ ā¤‰ā¤šāĨā¤šā¤žā¤°ā¤ŖāĨ‹ā¤‚ ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤ā¤• ⤰⤂⤗ ⤚āĨā¤¨āĨ‡ā¤‚", + "theme_setting_primary_color_subtitle": "ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋ⤕ ⤕āĨā¤°ā¤ŋā¤¯ā¤žā¤“ā¤‚ ⤔⤰ ā¤‰ā¤šāĨā¤šā¤žā¤°ā¤ŖāĨ‹ā¤‚ ⤕āĨ‡ ⤞ā¤ŋā¤ ā¤ā¤• ⤰⤂⤗ ⤚āĨā¤¨āĨ‡ā¤‚āĨ¤", "theme_setting_primary_color_title": "ā¤ĒāĨā¤°ā¤žā¤Ĩā¤Žā¤ŋ⤕ ⤰⤂⤗", "theme_setting_system_primary_color_title": "⤏ā¤ŋ⤏āĨā¤Ÿā¤Ž ⤰⤂⤗ ā¤•ā¤ž ⤉ā¤Ē⤝āĨ‹ā¤— ⤕⤰āĨ‡ā¤‚", "theme_setting_system_theme_switch": "ā¤‘ā¤ŸāĨ‹ā¤ŽāĨˆā¤Ÿā¤ŋ⤕ (⤏ā¤ŋ⤏āĨā¤Ÿā¤Ž ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗ ā¤Ģā¤ŧāĨ‰ā¤˛āĨ‹ ⤕⤰āĨ‡ā¤‚)", diff --git a/i18n/hr.json b/i18n/hr.json index 3ee15519c0..88fa57e230 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -1459,7 +1459,6 @@ "not_available": "N/A", "not_in_any_album": "Ni u jednom albumu", "not_selected": "Nije odabrano", - "note_apply_storage_label_to_previously_uploaded assets": "Napomena: Da biste primijenili oznaku pohrane na prethodno prenesene stavke, pokrenite", "notes": "BiljeÅĄke", "nothing_here_yet": "Ovdje joÅĄ nema ničega", "notification_permission_dialog_content": "Da biste omogućili obavijesti, idite u Postavke i odaberite dopusti.", @@ -1646,7 +1645,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# stavka ponovno dodijeljena} few {# stavke ponovno dodijeljene} other {# stavki ponovno dodijeljeno}} novoj osobi", "reassing_hint": "Dodijelite odabrane stavke postojećoj osobi", "recent": "Nedavno", - "recent-albums": "Nedavni albumi", + "recent_albums": "Nedavni albumi", "recent_searches": "Nedavne pretrage", "recently_added": "Nedavno dodano", "recently_added_page_title": "Nedavno dodano", diff --git a/i18n/hu.json b/i18n/hu.json index 232d492dd7..c2f3362e18 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -1604,7 +1604,6 @@ "not_available": "N/A", "not_in_any_album": "Nincs albumban", "not_selected": "Nincs kivÃĄlasztva", - "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)", "notes": "MegjegyzÊsek", "nothing_here_yet": "MÊg semmi sincs itt", "notification_permission_dialog_content": "Az ÊrtesítÊsek bekapcsolÃĄsÃĄhoz a BeÃĄllítÃĄsok menÃŧben vÃĄlaszd ki az EngedÊlyezÊs-t.", @@ -1806,7 +1805,7 @@ "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-albums": "LegutÃŗbbi albumok", + "recent_albums": "LegutÃŗbbi albumok", "recent_searches": "LegutÃŗbbi keresÊsek", "recently_added": "NemrÊg hozzÃĄadott", "recently_added_page_title": "NemrÊg hozzÃĄadott", diff --git a/i18n/id.json b/i18n/id.json index c63c963527..acde13c7d8 100644 --- a/i18n/id.json +++ b/i18n/id.json @@ -1613,7 +1613,6 @@ "not_available": "T/T", "not_in_any_album": "Tidak ada dalam album apa pun", "not_selected": "Belum dipilih", - "note_apply_storage_label_to_previously_uploaded assets": "Catatan: Untuk menerapkan Label Penyimpanan pada aset yang sebelumnya telah diunggah, jalankan", "notes": "Catatan", "nothing_here_yet": "Masih kosong", "notification_permission_dialog_content": "Untuk mengaktifkan notifikasi, buka Pengaturan lalu berikan izin.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "Menetapkan ulang {count, plural, one {# aset} other {# aset}} kepada orang baru", "reassing_hint": "Tetapkan aset yang dipilih ke orang yang sudah ada", "recent": "Terkini", - "recent-albums": "Album terkini", + "recent_albums": "Album terkini", "recent_searches": "Pencarian terkini", "recently_added": "Barusaja ditambahkan", "recently_added_page_title": "Baru Ditambahkan", diff --git a/i18n/it.json b/i18n/it.json index c2a7445f9b..e9cc787096 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -311,7 +311,7 @@ "search_jobs": "Cerca Attivitàâ€Ļ", "send_welcome_email": "Invia email di benvenuto", "server_external_domain_settings": "Dominio esterno", - "server_external_domain_settings_description": "Dominio per link condivisi pubblicamente, incluso http(s)://", + "server_external_domain_settings_description": "Dominio utilizzato per i link esterni", "server_public_users": "Utenti Pubblici", "server_public_users_description": "Tutti gli utenti (nome ed e-mail) sono elencati quando si aggiunge un utente agli album condivisi. Quando disabilitato, l'elenco degli utenti sarà disponibile solo per gli utenti amministratori.", "server_settings": "Impostazioni Server", @@ -794,6 +794,11 @@ "color": "Colore", "color_theme": "Colore Tema", "command": "Comando", + "command_palette_prompt": "Trova rapidamente pagine, azioni o comandi", + "command_palette_to_close": "per chiudere", + "command_palette_to_navigate": "per entrare", + "command_palette_to_select": "per selezionare", + "command_palette_to_show_all": "per mostrare tutto", "comment_deleted": "Commento eliminato", "comment_options": "Opzioni per i commenti", "comments_and_likes": "Commenti & mi piace", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "PERSONE", "exif_bottom_sheet_person_add_person": "Aggiungi nome", "exit_slideshow": "Esci dalla presentazione", + "expand": "Espandi", "expand_all": "Espandi tutto", "experimental_settings_new_asset_list_subtitle": "Lavori in corso", "experimental_settings_new_asset_list_title": "Attiva griglia foto sperimentale", @@ -1613,7 +1619,6 @@ "not_available": "N/A", "not_in_any_album": "In nessun album", "not_selected": "Non selezionato", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Per aggiungere l'etichetta dell'archiviazione alle risorse caricate in precedenza, esegui", "notes": "Note", "nothing_here_yet": "Ancora nulla qui", "notification_permission_dialog_content": "Per attivare le notifiche, vai alle Impostazioni e seleziona concedi.", @@ -1643,6 +1648,7 @@ "online": "Online", "only_favorites": "Solo preferiti", "open": "Apri", + "open_calendar": "Apri il calendario", "open_in_map_view": "Apri nella visualizzazione mappa", "open_in_openstreetmap": "Apri su OpenStreetMap", "open_the_search_filters": "Apri filtri di ricerca", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {Riassegnata # risorsa} other {Riassegnate # risorse}} ad una nuova persona", "reassing_hint": "Assegna le risorse selezionate ad una persona esistente", "recent": "Recenti", - "recent-albums": "Album recenti", + "recent_albums": "Album recenti", "recent_searches": "Ricerche recenti", "recently_added": "Aggiunti recentemente", "recently_added_page_title": "Aggiunti di recente", @@ -2184,6 +2190,7 @@ "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.", + "supporter": "Sostenitore", "swap_merge_direction": "Scambia direzione di unione", "sync": "Sincronizza", "sync_albums": "Sincronizza album", diff --git a/i18n/ja.json b/i18n/ja.json index 469d4c1d1e..218e615ac1 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -1613,7 +1613,6 @@ "not_available": "éŠį”¨ãĒし", "not_in_any_album": "おぎã‚ĸãƒĢバムãĢもå…ĨãŖãĻいãĒい", "not_selected": "選択ãĒし", - "note_apply_storage_label_to_previously_uploaded assets": "æŗ¨æ„: äģĨ前ãĢã‚ĸップロãƒŧドしたã‚ĸã‚ģットãĢ゚トãƒŦãƒŧジナベãƒĢã‚’éŠį”¨ã™ã‚‹ãĢはäģĨä¸‹ã‚’åŽŸčĄŒã—ãĻください", "notes": "æŗ¨æ„", "nothing_here_yet": "ぞだäŊ•ã‚‚į„Ąã„ã‚ˆã†ã§ã™", "notification_permission_dialog_content": "通įŸĨã‚’č¨ąå¯ã™ã‚‹ãĢã¯č¨­åŽšã‚’é–‹ã„ãĻã‚ĒãƒŗãĢしãĻください", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {#個} other {#個}}ãŽå†™įœŸ/動į”ģを新しいäēēį‰ŠãĢå‰˛ã‚ŠåŊ“ãĻぞした", "reassing_hint": "é¸æŠžã•ã‚ŒãŸå†™įœŸ/動į”ģをæ—ĸ存ぎäēēį‰ŠãĢå‰˛ã‚ŠåŊ“ãĻ", "recent": "最čŋ‘", - "recent-albums": "最čŋ‘ぎã‚ĸãƒĢバム", + "recent_albums": "最čŋ‘ぎã‚ĸãƒĢバム", "recent_searches": "最čŋ‘ぎ検į´ĸ", "recently_added": "最čŋ‘čŋŊåŠ ã•ã‚ŒãŸé …į›Ž", "recently_added_page_title": "最čŋ‘", diff --git a/i18n/ko.json b/i18n/ko.json index 147ace091d..5449bf1e44 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -1581,7 +1581,6 @@ "not_available": "ė—†ėŒ", "not_in_any_album": "ė•¨ë˛”ė— ė—†ėŒ", "not_selected": "ė„ íƒë˜ė§€ ė•ŠėŒ", - "note_apply_storage_label_to_previously_uploaded assets": "및溠: ė´ė „ė— ė—…ëĄœë“œí•œ 항ëĒŠė—ë„ ėŠ¤í† ëĻŦė§€ ë ˆė´ë¸”ė„ ė ėšŠí•˜ë ¤ëŠ´ ë‹¤ėŒė„ ė‹¤í–‰í•Šë‹ˆë‹¤,", "notes": "및溠", "nothing_here_yet": "땄링 ė•„ëŦ´ę˛ƒë„ ė—†ėŒ", "notification_permission_dialog_content": "ė•ŒëĻŧė„ í™œė„ąí™”í•˜ë ¤ëŠ´ ė„¤ė •ė—ė„œ ė•ŒëĻŧ ęļŒí•œė„ í—ˆėšŠí•˜ė„¸ėš”.", @@ -1780,7 +1779,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {항ëĒŠ #氜} other {항ëĒŠ #氜}}ëĨŧ 냈 ė¸ëŦŧė—ę˛Œ ėžŦė§€ė •í–ˆėŠĩ니다.", "reassing_hint": "ę¸°ėĄ´ ė¸ëŦŧ뗐 ė„ íƒí•œ 항ëĒŠ 할당", "recent": "ėĩœęˇŧ", - "recent-albums": "ėĩœęˇŧ ė•¨ë˛”", + "recent_albums": "ėĩœęˇŧ ė•¨ë˛”", "recent_searches": "ėĩœęˇŧ ę˛€ėƒ‰", "recently_added": "ėĩœęˇŧ ėļ”ę°€", "recently_added_page_title": "ėĩœęˇŧ ėļ”ę°€", diff --git a/i18n/lt.json b/i18n/lt.json index fcc5541f1a..fec5905957 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -1579,7 +1579,6 @@ "not_available": "Nepasiekiamas", "not_in_any_album": "Nė viename albume", "not_selected": "Nepasirinkta", - "note_apply_storage_label_to_previously_uploaded assets": "Pastaba: Priskirti Saugyklos ÅŊymą prie anksčiau įkeltÅŗ iÅĄtekliu, paleiskite ÅĄÄ¯", "notes": "Pastabos", "nothing_here_yet": "Kol kas tuÅĄÄia", "notification_permission_dialog_content": "PraneÅĄimÅŗ įgalinimui eikite į Nustatymus ir pasirinkite Leisti.", @@ -1774,7 +1773,7 @@ "read_changelog": "Skaityti pakeitimÅŗ sąraÅĄÄ…", "ready_for_upload": "ParuoÅĄta įkėlimui", "recent": "Naujausi", - "recent-albums": "Naujausi albumai", + "recent_albums": "Naujausi albumai", "recent_searches": "Naujausios paieÅĄkos", "recently_added": "Neseniai pridėta", "recently_added_page_title": "Neseniai pridėta", @@ -1824,12 +1823,18 @@ "replace_with_upload": "Pakeisti naujai įkeltu failu", "repository": "Repozitoriumas", "require_password": "Reikalauti slaptaÅžodÅžio", + "require_user_to_change_password_on_first_login": "Reikalauti, kad vartotojas pakeistÅŗ slaptaÅžodį pirmą kartą prisijungdamas", "rescan": "Perskenuoti", "reset": "Atstatyti", - "reset_password": "Atstayti slaptaÅžodį", + "reset_password": "Atstatyti slaptaÅžodį", + "reset_people_visibility": "Atstatyti ÅžmoniÅŗ matomumą", "reset_pin_code": "Atsatyti PIN kodą", "reset_pin_code_description": "Jei pamirÅĄote PIN kodą, galite susisiekti su serverio administratoriumi, kad jis jį atstatytÅŗ", + "reset_pin_code_success": "Sėkmingai atstatytas PIN kodas", "reset_pin_code_with_password": "PIN kodą visada galite atkurti naudodami savo slaptaÅžodį", + "reset_sqlite": "Atstatyti SQLite duomenÅŗ bazę", + "reset_sqlite_confirmation": "Ar tikrai norite atstatyti SQLite duomenÅŗ bazę? Turėsite atsijungti ir vėl prisijungti, kad iÅĄ naujo sinchronizuotumėte duomenis", + "reset_sqlite_success": "Sėkmingai atstatyta SQLite duomenÅŗ bazė", "reset_to_default": "Atkurti numatytuosius", "resolution": "Rezoliucija", "resolve_duplicates": "Sutvarkyti dublikatus", @@ -1839,7 +1844,15 @@ "restore_trash_action_prompt": "{count} atstatyta iÅĄ ÅĄiukÅĄliadėŞės", "restore_user": "Atkurti naudotoją", "restored_asset": "Atkurti elementą", + "resume": "Tęsti", + "resume_paused_jobs": "Tęsti {count, plural, one {# pristabdytą darbą} other {# pristabdytus darbus}}", + "retry_upload": "Bandyti iÅĄsiÅŗsti dar kartą", "review_duplicates": "PerÅžiÅĢrėti dublikatus", + "review_large_files": "PerÅžiÅĢrėti didelius failus", + "role": "Rolė", + "role_editor": "Redaktorius", + "role_viewer": "Stebėtojas", + "running": "Vykdoma", "save": "IÅĄsaugoti", "save_to_gallery": "IÅĄsaugoti galerijoje", "saved": "IÅĄsaugota", @@ -1863,6 +1876,7 @@ "search_by_filename_example": "pvz. IMG_1234.JPG arba PNG", "search_by_ocr": "IeÅĄkoti pagal OCR", "search_by_ocr_example": "Latte", + "search_camera_lens_model": "IeÅĄkoti objektyvo modelio...", "search_camera_make": "IeÅĄkoti pagal kameros gamintoją...", "search_camera_model": "IeÅĄkoti kameros modelį...", "search_city": "IeÅĄkoti miesto...", @@ -1883,12 +1897,15 @@ "search_filter_people_title": "Pasirinkti asmenis", "search_filter_star_rating": "ÄŽvertinimas", "search_for": "IeÅĄkoti ko", + "search_for_existing_person": "IeÅĄkoti įvardinto asmens", "search_no_more_result": "Nėra daugiau rezultatÅŗ", "search_no_people": "Be asmenÅŗ", "search_no_people_named": "Nėra ÅžmoniÅŗ vardu „{name}“", "search_no_result": "RezultatÅŗ nerasta, pabandykite kitą paieÅĄkos terminą ar derinį", "search_options": "PaieÅĄkos parinktys", "search_page_categories": "Kategorijos", + "search_page_motion_photos": "Judanti Foto", + "search_page_no_objects": "Objekto info nepasiekiama", "search_page_no_places": "Vietovės info nepasiekiama", "search_page_screenshots": "Ekrano nuotraukos", "search_page_search_photos_videos": "IeÅĄkokite nuotraukÅŗ ir vaizdo įraÅĄÅŗ", @@ -1902,22 +1919,35 @@ "search_rating": "IeÅĄkoti pagal įvertinimą...", "search_result_page_new_search_hint": "Nauja PaieÅĄka", "search_settings": "IeÅĄkoti nustatymÅŗ", + "search_state": "IeÅĄkoti valstijos/apskrities...", + "search_suggestion_list_smart_search_hint_1": "IÅĄmanioji paieÅĄka įjungta pagal numatytuosius nustatymus, metaduomenÅŗ paieÅĄkai naudokite sintaksę ", "search_suggestion_list_smart_search_hint_2": "PaieÅĄka", "search_tags": "IeÅĄkoti ÅžymÅŗ...", "search_timezone": "IeÅĄkoti laiko zonos...", "search_type": "PaieÅĄkos tipas", "search_your_photos": "IeÅĄkoti nuotraukÅŗ", + "searching_locales": "IeÅĄkoma vietoviÅŗ...", + "second": "Sekundė", + "see_all_people": "Pamatyti visus asmenis", "select": "Pasirinkti", + "select_album": "Rinktis albumą", + "select_album_cover": "Rinktis albumo virÅĄelį", + "select_albums": "Rinktis albumus", + "select_all": "Pasirinkti visus", "select_all_duplicates": "Pasirinkti visus dublikatus", "select_all_in": "PaÅžymėti visus esančius {group}", "select_avatar_color": "Pasirinkti avataro spalvą", "select_count": "{count, plural, one {Pasirinkti #} other {Pasirinkti #}}", + "select_cutoff_date": "Pasirinkite galutinę datą", "select_face": "Pasirinkti veidą", "select_featured_photo": "Pasirinkti rodomą nuotrauką", "select_from_computer": "Pasirinkti iÅĄ kompiuterio", "select_keep_all": "Visus paÅžymėti \"Palikti\"", "select_library_owner": "Pasirinkti bibliotekos savininką", "select_new_face": "Pasirinkti naują veidą", + "select_people": "Pasirinkti asmenis", + "select_person": "Pasirinkti asmenį", + "select_person_to_tag": "Pasirinkti asmenį Åžymai", "select_photos": "Pasirinkti nuotraukas", "select_trash_all": "Visus paÅžymėti \"IÅĄmesti\"", "select_user_for_sharing_page_err_album": "Nepavyko sukurti albumo", @@ -1926,22 +1956,29 @@ "selected_gps_coordinates": "Pasirinkti GPS Koordinates", "send_message": "SiÅŗsti Åžinutę", "send_welcome_email": "SiÅŗsti sveikinimo el. laiÅĄką", - "server_info_box_app_version": "Programėlės versija", + "server_endpoint": "Serverio Galinis TaÅĄkas", + "server_info_box_app_version": "Programos versija", "server_info_box_server_url": "Serverio URL", "server_offline": "Serveris nepasiekiamas", "server_online": "Serveris pasiekiamas", "server_privacy": "Serverio Privatumas", "server_restarting_description": "Å is puslapis atsinaujins neuÅžilgo.", + "server_restarting_title": "Serveris restartuoja", "server_stats": "Serverio statistika", + "server_update_available": "Yra Serverio atnaujinimas", "server_version": "Serverio versija", "set": "Nustatyti", + "set_as_album_cover": "Naudoti kaip albumo virÅĄelį", + "set_as_featured_photo": "Naudoti foto asmens profiliui", "set_as_profile_picture": "Nustatyti kaip profilio nuotrauką", "set_date_of_birth": "Nustatyti gimimo datą", "set_profile_picture": "Nustatyti profilio nuotrauką", "set_slideshow_to_fullscreen": "Nustatyti skaidriÅŗ perÅžiÅĢrą per visą ekraną", "set_stack_primary_asset": "Nustatyti kaip pagrindinį elementą", "setting_image_viewer_help": "Detali perÅžiÅĢra pirmiausia įkelia maŞą miniatiÅĢrą, tada įkelia vidutinio dydÅžio versiją (jei įjungta) ir galiausiai įkelia originalą (jei įjungta).", + "setting_image_viewer_original_subtitle": "ÄŽjunkite, kad įkeltumėte originalÅŗ pilnos raiÅĄkos vaizdą (didelį!). IÅĄjunkite, kad sumaÅžintumėte duomenÅŗ naudojimą (tiek tinkle, tiek įrenginio talpykloje).", "setting_image_viewer_original_title": "UÅžkrauti originalią nuotrauką", + "setting_image_viewer_preview_subtitle": "ÄŽjunkite, jei norite įkelti vidutinės raiÅĄkos vaizdą. IÅĄjunkite, jei norite tiesiogiai įkelti originalą ar naudoti tik miniatiÅĢrą.", "setting_image_viewer_preview_title": "UÅžkrauti perÅžiÅĢros nuotrauką", "setting_image_viewer_title": "Nuotraukos", "setting_languages_apply": "Pritaikyti", @@ -1976,7 +2013,11 @@ "shared_album_activities_input_disable": "Komentarai iÅĄjungti", "shared_album_activity_remove_content": "Ar norite iÅĄtrinti ÅĄią veiklą?", "shared_album_activity_remove_title": "IÅĄtrinti veiklą", + "shared_album_section_people_action_error": "Klaida iÅĄeinant/ÅĄalinant iÅĄ albumo", + "shared_album_section_people_action_leave": "PaÅĄalinti naudotoją iÅĄ albumo", + "shared_album_section_people_action_remove_user": "PaÅĄalinti naudotoją iÅĄ albumo", "shared_album_section_people_title": "ASMENYS", + "shared_by": "Bendrina", "shared_by_user": "Bendrina {user}", "shared_by_you": "Bendrinama jÅĢsÅŗ", "shared_from_partner": "Nuotraukos iÅĄ {partner}", @@ -1984,6 +2025,9 @@ "shared_link_app_bar_title": "Dalinimosi Nuorodos", "shared_link_clipboard_copied_massage": "Nukopijuota į iÅĄkarpinę", "shared_link_clipboard_text": "Nuoroda: {link}\nSlaptaÅžodis: {password}", + "shared_link_create_error": "Klaida kuriant bendrinimo nuorodą", + "shared_link_custom_url_description": "Pasiekite ÅĄią bendrinimo nuorodą naudodami tinkintą URL", + "shared_link_edit_description_hint": "ÄŽveskite bendrinimo apraÅĄymą", "shared_link_edit_expire_after_option_day": "1 diena", "shared_link_edit_expire_after_option_days": "{count} dienÅŗ", "shared_link_edit_expire_after_option_hour": "1 valanda", @@ -1992,23 +2036,32 @@ "shared_link_edit_expire_after_option_minutes": "{count} minučiÅŗ", "shared_link_edit_expire_after_option_months": "{count} mėnesiÅŗ", "shared_link_edit_expire_after_option_year": "{count} metÅŗ", - "shared_link_edit_submit_button": "Dalinimosi Nuorodos", + "shared_link_edit_password_hint": "ÄŽveskite bendrinimo slaptaÅžodį", + "shared_link_edit_submit_button": "Atnaujinti nuorodą", + "shared_link_error_server_url_fetch": "Nepavyksta gauti serverio url", "shared_link_expires_day": "Galiojimas baigsis uÅž {count} dienos", "shared_link_expires_days": "Galiojimas baigsis uÅž {count} dienÅŗ", "shared_link_expires_hour": "Galiojimas baigsis uÅž {count} valandos", "shared_link_expires_hours": "Galiojimas baigsis uÅž {count} valandÅŗ", "shared_link_expires_minute": "Galiojimas baigsis uÅž {count} minutės", "shared_link_expires_minutes": "Galiojimas baigsis uÅž {count} minučiÅŗ", + "shared_link_expires_never": "Galiojimas baigiasi ∞", "shared_link_expires_second": "Galiojimas baigsis uÅž {count} sekundės", "shared_link_expires_seconds": "Galiojimas baigsis uÅž {count} sekundÅžiÅŗ", + "shared_link_individual_shared": "Asmuo pasidalintas", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_manage_links": "Valdyti Bendrinimo nuorodas", "shared_link_options": "Bendrinimo nuorodos parametrai", + "shared_link_password_description": "Bendrinimo nuorodos prieigai reikalingas slaptaÅžodis", "shared_links": "Bendrinimo nuorodos", + "shared_links_description": "Dalintis foto ir video su nuoroda", "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_me": "Bendrinama su manimi", "shared_with_partner": "Pasidalinta su {partner}", "sharing": "Dalijimasis", "sharing_enter_password": "Norėdami perÅžiÅĢrėti ÅĄÄ¯ puslapį, įveskite slaptaÅžodį.", "sharing_page_album": "Bendrinami albumai", + "sharing_page_description": "Kurkite bendrinamus albumus, kad galėtumėte dalintis foto ir video su Åžmonėmis savo tinkle.", "sharing_page_empty_list": "TUŠČIAS SĄRAÅ AS", "sharing_sidebar_description": "Rodyti bendrinimo rodinio nuorodą ÅĄoninėje juostoje", "sharing_silver_appbar_create_shared_album": "Naujas bendrinamas albumas", @@ -2027,11 +2080,17 @@ "show_metadata": "Rodyti metaduomenis", "show_or_hide_info": "Rodyti arba slėpti informaciją", "show_password": "Rodyti slaptaÅžodį", + "show_person_options": "Rodyti asmens parinktis", "show_progress_bar": "Rodyti progreso juostą", + "show_schema": "Rodyti schemą", "show_search_options": "Rodyti paieÅĄkos parinktis", + "show_shared_links": "Rodyti bendrinamas nuorodas", "show_slideshow_transition": "Rodyti perėjimą tarp skaidriÅŗ", "show_supporter_badge": "Rėmėjo Åženklelis", "show_supporter_badge_description": "Rodyti rėmėjo Åženklelį", + "show_text_recognition": "Rodyti teksto atpaÅžinimą", + "show_text_search_menu": "Rodyti teksto paieÅĄkos meniu", + "shuffle": "IÅĄmaiÅĄyti", "sidebar": "Å oninė juosta", "sidebar_display_description": "Rodyti rodinio nuorodą ÅĄoninėje juostoje", "sign_out": "Atsijungti", @@ -2041,6 +2100,8 @@ "skip_to_folders": "Praleisti iki aplankÅŗ", "skip_to_tags": "Praleisti iki ÅžymiÅŗ", "slideshow": "SkaidriÅŗ perÅžiÅĢra", + "slideshow_repeat": "Kartoti skaidres", + "slideshow_repeat_description": "Pradėti iÅĄ pradÅžiÅŗ, kai skaidrės baigiasi", "slideshow_settings": "SkaidriÅŗ perÅžiÅĢros nustatymai", "sort_albums_by": "Rikiuoti albumus pagal...", "sort_created": "SukÅĢrimo data", @@ -2062,7 +2123,7 @@ "start": "Pradėti", "start_date": "PradÅžios data", "start_date_before_end_date": "PradÅžios data turi bÅĢti ankstesnė uÅž pabaigos datą", - "state": "Valstija", + "state": "Valstija/Apskritis", "status": "Statusas", "stop_casting": "Nutraukti transliavimą", "stop_motion_photo": "Sustabdyti Judančią Foto", @@ -2104,21 +2165,34 @@ "theme": "Tema", "theme_selection": "Temos pasirinkimas", "theme_selection_description": "AutomatiÅĄkai nustatykite ÅĄviesią arba tamsią temą pagal narÅĄyklės sistemos nustatymus", + "theme_setting_asset_list_storage_indicator_title": "Rodyti saugyklos indikatoriÅŗ elementÅŗ plytelėse", "theme_setting_asset_list_tiles_per_row_title": "ElementÅŗ per eilutę ({count})", + "theme_setting_colorful_interface_subtitle": "Fono pavirÅĄiams uÅžtepkite pagrindinę spalvą.", + "theme_setting_colorful_interface_title": "Spalvinga sąsaja", + "theme_setting_image_viewer_quality_subtitle": "Koreguoti detaliÅŗ vaizdÅŗ perÅžiÅĢros kokybę", + "theme_setting_image_viewer_quality_title": "Vaizdo perÅžiÅĢros priemonės kokybė", + "theme_setting_primary_color_subtitle": "Pasirinkite spalvą pagrindiniams veiksmams ir akcentams.", "theme_setting_primary_color_title": "Pagrindinė spalva", "theme_setting_system_primary_color_title": "Naudoti sistemos spalvą", "theme_setting_system_theme_switch": "Automatinė (Naudoti sistemos nustatymus)", + "theme_setting_theme_subtitle": "Pasirinkite programos temos nustatymą", "theme_setting_three_stage_loading_subtitle": "TrijÅŗ etapÅŗ įkėlimas gali padidinti įkėlimo naÅĄumą, tačiau sukelia Åžymiai didesnę tinklo apkrovą", + "theme_setting_three_stage_loading_title": "ÄŽjungti trijÅŗ etapÅŗ įkėlimą", "then": "Tada", "they_will_be_merged_together": "Jie bus sujungti kartu", + "third_party_resources": "Trečios Å alies IÅĄtekliai", "time": "Laikas", "time_based_memories": "Atsiminimai pagal laiką", + "time_based_memories_duration": "Kiekvieno vaizdo rodymo laikas sekundėmis.", "timeline": "Laiko skalė", "timezone": "Laiko juosta", "to_archive": "Archyvuoti", "to_change_password": "Pakeisti slaptaÅžodį", "to_favorite": "ÄŽtraukti prie mėgstamiausiÅŗ", "to_login": "Prisijungti", + "to_multi_select": "pasirinkti kelis elementus", + "to_parent": "Persikelti į virÅĄÅŗ", + "to_select": "į pasirinkimą", "to_trash": "IÅĄmesti", "toggle_settings": "ÄŽjungti nustatymus", "toggle_theme_description": "ÄŽjungti temą", @@ -2139,6 +2213,9 @@ "trash_page_select_assets_btn": "Pasirinkti elementus", "trash_page_title": "Å iukÅĄliÅŗ ({count})", "trashed_items_will_be_permanently_deleted_after": "ÄŽ ÅĄiukÅĄliadėŞę perkelti elementai bus visam laikui iÅĄtrinti po {days, plural, one {# dienos} other {# dienÅŗ}}.", + "trigger_asset_uploaded": "Elementas IÅĄsiÅŗstas", + "trigger_person_recognized": "Asmuo AtpaÅžintas", + "troubleshoot": "Å alinti triktis", "type": "Tipas", "unable_to_change_pin_code": "Negalima pakeisti PIN kodo", "unable_to_check_version": "Nepavyko patvirtinti programos/serverio versijos", diff --git a/i18n/lv.json b/i18n/lv.json index a1de76cc44..f5c96d8bcb 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -1235,7 +1235,6 @@ "not_available": "Nav pieejams", "not_in_any_album": "Nav nevienā albumā", "not_selected": "Nav izvēlēts", - "note_apply_storage_label_to_previously_uploaded assets": "PiezÄĢme: Lai piemērotu glabātuves nosaukumu iepriekÅĄ augÅĄupielādētiem failiem, izpildiet", "notes": "PiezÄĢmes", "nothing_here_yet": "Å eit vēl nekā nav", "notification_permission_dialog_content": "Lai iespējotu paziņojumus, atveriet IestatÄĢjumi un atlasiet AtÄŧaut.", diff --git a/i18n/ml.json b/i18n/ml.json index 7fc4475bc5..9e93ce9fc6 100644 --- a/i18n/ml.json +++ b/i18n/ml.json @@ -1468,7 +1468,6 @@ "not_available": "ⴞⴭāĩā´¯ā´Žā´˛āĩā´˛", "not_in_any_album": "ā´’ā´°āĩ ā´†āĩŊā´Ŧā´¤āĩā´¤ā´ŋā´˛āĩā´Žā´ŋā´˛āĩā´˛", "not_selected": "ā´¤ā´ŋā´°ā´žāĩā´žāĩ†ā´Ÿāĩā´¤āĩā´¤ā´ŋⴟāĩā´Ÿā´ŋā´˛āĩā´˛", - "note_apply_storage_label_to_previously_uploaded assets": "ā´•āĩā´ąā´ŋā´Ēāĩā´Ēāĩ: ā´Žāĩā´Žāĩā´Ēāĩ ā´…ā´Ēāĩâ€Œā´˛āĩ‹ā´Ąāĩ ⴚāĩ†ā´¯āĩā´¤ ā´…ā´¸ā´ąāĩā´ąāĩā´•ā´ŗā´ŋāĩŊ ā´¸āĩā´ąāĩā´ąāĩ‹ā´ąāĩ‡ā´œāĩ ā´˛āĩ‡ā´ŦāĩŊ ā´Ēāĩā´°ā´¯āĩ‹ā´—ā´ŋā´•āĩā´•ā´žāĩģ, ⴇⴤāĩ ā´Ēāĩā´°ā´ĩāĩŧā´¤āĩā´¤ā´ŋā´Ēāĩā´Ēā´ŋā´•āĩā´•āĩā´•", "notes": "ā´•āĩā´ąā´ŋā´Ēāĩā´Ēāĩā´•āĩž", "nothing_here_yet": "ā´‡ā´ĩā´ŋⴟāĩ† ⴇⴤāĩā´ĩā´°āĩ† ā´’ā´¨āĩā´¨āĩā´Žā´ŋā´˛āĩā´˛", "notification_permission_dialog_content": "ā´…ā´ąā´ŋā´¯ā´ŋā´Ēāĩā´Ēāĩā´•āĩž ā´Ēāĩā´°ā´ĩāĩŧā´¤āĩā´¤ā´¨ā´•āĩā´ˇā´Žā´Žā´žā´•āĩā´•ā´žāĩģ, ā´•āĩā´°ā´Žāĩ€ā´•ā´°ā´Ŗā´™āĩā´™ā´ŗā´ŋā´˛āĩ‡ā´•āĩā´•āĩ ā´Ēāĩ‹ā´¯ā´ŋ 'ā´…ā´¨āĩā´ĩā´Ļā´ŋā´•āĩā´•āĩā´•' ā´¤ā´ŋā´°ā´žāĩā´žāĩ†ā´Ÿāĩā´•āĩā´•āĩā´•.", @@ -1663,7 +1662,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# ā´…ā´¸ā´ąāĩā´ąāĩ} other {# ā´…ā´¸ā´ąāĩā´ąāĩā´•āĩž}} ā´’ā´°āĩ ā´Ēāĩā´¤ā´ŋā´¯ ā´ĩāĩā´¯ā´•āĩā´¤ā´ŋā´•āĩā´•āĩ ā´ĩāĩ€ā´Ŗāĩā´Ÿāĩā´‚ ā´¨āĩŊā´•ā´ŋ", "reassing_hint": "ā´¤ā´ŋā´°ā´žāĩā´žāĩ†ā´Ÿāĩā´¤āĩā´¤ ā´…ā´¸ā´ąāĩā´ąāĩā´•āĩž ā´¨ā´ŋā´˛ā´ĩā´ŋā´˛āĩā´ŗāĩā´ŗ ā´’ā´°āĩ ā´ĩāĩā´¯ā´•āĩā´¤ā´ŋā´•āĩā´•āĩ ā´¨āĩŊā´•āĩā´•", "recent": "ā´¸ā´Žāĩ€ā´Ēā´•ā´žā´˛ā´‚", - "recent-albums": "ā´¸ā´Žāĩ€ā´Ēā´•ā´žā´˛ ā´†āĩŊā´Ŧā´™āĩā´™āĩž", + "recent_albums": "ā´¸ā´Žāĩ€ā´Ēā´•ā´žā´˛ ā´†āĩŊā´Ŧā´™āĩā´™āĩž", "recent_searches": "ā´¸ā´Žāĩ€ā´Ēā´•ā´žā´˛ ā´¤ā´ŋⴰⴝⴞāĩā´•āĩž", "recently_added": "ā´…ā´Ÿāĩā´¤āĩā´¤ā´ŋⴟāĩ† ⴚāĩ‡āĩŧā´¤āĩā´¤ā´¤āĩ", "recently_added_page_title": "ā´…ā´Ÿāĩā´¤āĩā´¤ā´ŋⴟāĩ† ⴚāĩ‡āĩŧā´¤āĩā´¤ā´¤āĩ", diff --git a/i18n/mr.json b/i18n/mr.json index be0c96f9e9..f31b080e37 100644 --- a/i18n/mr.json +++ b/i18n/mr.json @@ -1463,7 +1463,6 @@ "not_available": "⤉ā¤Ē⤞ā¤ŦāĨā¤§ ā¤¨ā¤žā¤šāĨ€", "not_in_any_album": "⤕āĨ‹ā¤Ŗā¤¤āĨā¤¯ā¤žā¤šāĨ€ ⤅⤞āĨā¤Ŧā¤Žā¤Žā¤§āĨā¤¯āĨ‡ ā¤¨ā¤žā¤šāĨ€", "not_selected": "⤍ā¤ŋā¤ĩā¤Ąā¤˛āĨ‡ā¤˛āĨ‡ ā¤¨ā¤žā¤šāĨ€", - "note_apply_storage_label_to_previously_uploaded assets": "⤍āĨ‹ā¤Ÿ: ⤆⤧āĨ€ ⤅ā¤Ē⤞āĨ‹ā¤Ą ⤕āĨ‡ā¤˛āĨ‡ā¤˛āĨā¤¯ā¤ž ⤅āĨ…⤏āĨ‡ā¤ŸāĨā¤¸ā¤ĩ⤰ ⤏āĨā¤ŸāĨ‹ā¤°āĨ‡ā¤œ ⤞āĨ‡ā¤Ŧ⤞ ā¤˛ā¤žā¤—āĨ‚ ⤕⤰⤪āĨā¤¯ā¤žā¤¸ā¤žā¤ āĨ€ ā¤šā¤ž ⤆ā¤ĻāĨ‡ā¤ļ ā¤šā¤žā¤˛ā¤ĩā¤ž", "notes": "⤍āĨ‹ā¤ŸāĨā¤¸", "nothing_here_yet": "⤇ā¤ĨāĨ‡ ā¤…ā¤œāĨ‚⤍ ā¤•ā¤žā¤šāĨ€ ā¤¨ā¤žā¤šāĨ€", "notification_permission_dialog_content": "⤏āĨ‚ā¤šā¤¨ā¤ž ⤏⤕āĨā¤ˇā¤Ž ⤕⤰⤪āĨā¤¯ā¤žā¤¸ā¤žā¤ āĨ€ ⤏āĨ‡ā¤Ÿā¤ŋ⤂⤗āĨā¤œā¤Žā¤§āĨā¤¯āĨ‡ ā¤œā¤ž ⤆⤪ā¤ŋ ⤅⤍āĨā¤Žā¤¤āĨ€ ā¤ĻāĨā¤¯ā¤ž.", @@ -1658,7 +1657,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# ā¤†ā¤¯ā¤Ÿā¤Ž} other {# ā¤†ā¤¯ā¤Ÿā¤Ž}} ⤍ā¤ĩāĨā¤¯ā¤ž ā¤ĩāĨā¤¯ā¤•āĨā¤¤āĨ€ā¤•ā¤ĄāĨ‡ ā¤ĒāĨā¤¨āĨā¤šā¤ž ⤍ā¤ŋ⤝āĨā¤•āĨā¤¤ ⤕āĨ‡ā¤˛āĨ‡", "reassing_hint": "⤍ā¤ŋā¤ĩā¤Ąā¤˛āĨ‡ā¤˛āĨ‡ ā¤†ā¤¯ā¤Ÿā¤Ž ā¤ĩā¤ŋā¤ĻāĨā¤¯ā¤Žā¤žā¤¨ ā¤ĩāĨā¤¯ā¤•āĨā¤¤āĨ€ā¤•ā¤ĄāĨ‡ ⤍ā¤ŋ⤝āĨā¤•āĨā¤¤ ā¤•ā¤°ā¤ž", "recent": "⤅⤞āĨ€ā¤•ā¤ĄāĨ€ā¤˛", - "recent-albums": "⤅⤞āĨ€ā¤•ā¤ĄāĨ€ā¤˛ ⤅⤞āĨā¤Ŧā¤Ž", + "recent_albums": "⤅⤞āĨ€ā¤•ā¤ĄāĨ€ā¤˛ ⤅⤞āĨā¤Ŧā¤Ž", "recent_searches": "⤅⤞āĨ€ā¤•ā¤ĄāĨ€ā¤˛ ā¤ļāĨ‹ā¤§", "recently_added": "⤍āĨā¤•⤤āĨ‡ā¤š ⤜āĨ‹ā¤Ąā¤˛āĨ‡ā¤˛āĨ‡", "recently_added_page_title": "⤍āĨā¤•⤤āĨ‡ā¤š ⤜āĨ‹ā¤Ąā¤˛āĨ‡ā¤˛āĨ‡", diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index 6b8a80ebb9..564c3c0de9 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -1604,7 +1604,6 @@ "not_available": "Ikke tilgjengelig", "not_in_any_album": "Ikke i noe album", "not_selected": "Ikke valgt", - "note_apply_storage_label_to_previously_uploaded assets": "Merk: For ÃĨ bruke lagringsetiketten pÃĨ tidligere opplastede filer, kjør", "notes": "Notater", "nothing_here_yet": "Ingenting her enda", "notification_permission_dialog_content": "For ÃĨ aktivere notifikasjoner, gÃĨ til Innstillinger og velg tillat.", @@ -1806,7 +1805,7 @@ "reassigned_assets_to_new_person": "Flyttet {count, plural, one {# element} other {# elementer}} til en ny person", "reassing_hint": "Tilordne valgte eiendeler til en eksisterende person", "recent": "Nylig", - "recent-albums": "Nylige album", + "recent_albums": "Nylige album", "recent_searches": "Nylige søk", "recently_added": "Nylig lagt til", "recently_added_page_title": "Nylig oppført", diff --git a/i18n/nl.json b/i18n/nl.json index a15a2562af..24197a15b8 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -311,7 +311,7 @@ "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)://", + "server_external_domain_settings_description": "Domein voor externe links", "server_public_users": "Openbare gebruikerslijst", "server_public_users_description": "Alle gebruikers (met naam en e-mailadres) worden weergegeven wanneer een gebruiker wordt toegevoegd aan gedeelde albums. Wanneer uitgeschakeld, is de gebruikerslijst alleen beschikbaar voor beheerders.", "server_settings": "Serverinstellingen", @@ -793,7 +793,12 @@ "collapse_all": "Alles inklappen", "color": "Kleur", "color_theme": "Kleurenthema", - "command": "Opdracht", + "command": "Commando", + "command_palette_prompt": "Vind snel pagina's, acties of commando's", + "command_palette_to_close": "om te sluiten", + "command_palette_to_navigate": "om te navigeren", + "command_palette_to_select": "om te selecteren", + "command_palette_to_show_all": "om alles te tonen", "comment_deleted": "Opmerking verwijderd", "comment_options": "Opties voor opmerkingen", "comments_and_likes": "Opmerkingen & likes", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "MENSEN", "exif_bottom_sheet_person_add_person": "Naam toevoegen", "exit_slideshow": "Diavoorstelling sluiten", + "expand": "Uitklappen", "expand_all": "Alles uitvouwen", "experimental_settings_new_asset_list_subtitle": "Werk in uitvoering", "experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen", @@ -1613,7 +1619,6 @@ "not_available": "n.v.t.", "not_in_any_album": "Niet in een album", "not_selected": "Niet geselecteerd", - "note_apply_storage_label_to_previously_uploaded assets": "Opmerking: om het opslaglabel toe te passen op eerder geÃŧploade items, voer de volgende taak uit", "notes": "Opmerkingen", "nothing_here_yet": "Hier staan nog geen items", "notification_permission_dialog_content": "Om meldingen in te schakelen, ga naar Instellingen en selecteer toestaan.", @@ -1643,6 +1648,7 @@ "online": "Online", "only_favorites": "Alleen favorieten", "open": "Openen", + "open_calendar": "Open kalender", "open_in_map_view": "Openen in kaartweergave", "open_in_openstreetmap": "Openen in OpenStreetMap", "open_the_search_filters": "Open de zoekfilters", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# item} other {# items}} opnieuw toegewezen aan een nieuw persoon", "reassing_hint": "Geselecteerde items toewijzen aan een bestaand persoon", "recent": "Recent", - "recent-albums": "Recente albums", + "recent_albums": "Recente albums", "recent_searches": "Recente zoekopdrachten", "recently_added": "Onlangs toegevoegd", "recently_added_page_title": "Recent toegevoegd", @@ -2184,6 +2190,7 @@ "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.", + "supporter": "Supporter", "swap_merge_direction": "Wissel richting voor samenvoegen om", "sync": "Synchroniseren", "sync_albums": "Albums synchroniseren", @@ -2295,7 +2302,7 @@ "unstack_action_prompt": "{count} item(s) ontstapeld", "unstacked_assets_count": "{count, plural, one {# item} other {# items}} ontstapeld", "unsupported_field_type": "Veldtype niet ondersteund", - "untagged": "Ongemarkeerd", + "untagged": "Zonder tags", "untitled_workflow": "Naamloze werkstroom", "up_next": "Volgende", "update_location_action_prompt": "Werk de locatie bij van {count} geselecteerde items met:", diff --git a/i18n/pl.json b/i18n/pl.json index fd8e65ea7b..d98533a41e 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -1613,7 +1613,6 @@ "not_available": "Nie dotyczy", "not_in_any_album": "Bez albumu", "not_selected": "Nie wybrano", - "note_apply_storage_label_to_previously_uploaded assets": "Uwaga: Aby przypisać etykietę magazynowania do wcześniej przesłanych zasobÃŗw, uruchom", "notes": "Uwagi", "nothing_here_yet": "Nic tu jeszcze nie ma", "notification_permission_dialog_content": "Aby włączyć powiadomienia, przejdÅē do Ustawień i wybierz opcję Zezwalaj.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "Przypisano ponownie {count, plural, one {# zasÃŗb} other {# zasobÃŗw}} do nowej osoby", "reassing_hint": "Przypisz wybrane zasoby do istniejącej osoby", "recent": "Ostatnie", - "recent-albums": "Ostatnie albumy", + "recent_albums": "Ostatnie albumy", "recent_searches": "Ostatnie wyszukiwania", "recently_added": "Ostatnio dodane", "recently_added_page_title": "Ostatnio Dodane", @@ -2065,7 +2064,7 @@ "shared_by_you": "Udostępnione przez ciebie", "shared_from_partner": "Zdjęcia od {partner}", "shared_intent_upload_button_progress_text": "{current} / {total} Przesłano", - "shared_link_app_bar_title": "Udostępnione linki", + "shared_link_app_bar_title": "Udostępnione", "shared_link_clipboard_copied_massage": "Skopiowane do schowka", "shared_link_clipboard_text": "Link: {link}\nHasło: {password}", "shared_link_create_error": "Błąd podczas tworzenia linka do udostępnienia", diff --git a/i18n/pt.json b/i18n/pt.json index 2d59105914..4d281c94fa 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -1613,7 +1613,6 @@ "not_available": "N/A", "not_in_any_album": "NÃŖo estÃĄ em nenhum ÃĄlbum", "not_selected": "NÃŖo selecionado", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o RÃŗtulo de Armazenamento a ficheiros carregados anteriormente, execute o", "notes": "Notas", "nothing_here_yet": "Ainda nÃŖo existe nada aqui", "notification_permission_dialog_content": "Para ativar as notificaçÃĩes, vÃĄ em ConfiguraçÃĩes e selecione permitir.", @@ -1815,7 +1814,7 @@ "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-albums": "Álbuns recentes", + "recent_albums": "Álbuns recentes", "recent_searches": "Pesquisas recentes", "recently_added": "Adicionados Recentemente", "recently_added_page_title": "Adicionado recentemente", diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index 2a341e2cc5..7f2845fc53 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -1613,7 +1613,6 @@ "not_available": "N/A", "not_in_any_album": "Fora de ÃĄlbum", "not_selected": "NÃŖo selecionado", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rÃŗtulo de armazenamento a arquivos enviados anteriormente, execute o", "notes": "Notas", "nothing_here_yet": "Ainda nÃŖo existe nada aqui", "notification_permission_dialog_content": "Para ativar as notificaçÃĩes, vÃĄ em ConfiguraçÃĩes e selecione permitir.", @@ -1815,7 +1814,7 @@ "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-albums": "Álbuns recentes", + "recent_albums": "Álbuns recentes", "recent_searches": "Pesquisas recentes", "recently_added": "Adicionado recentemente", "recently_added_page_title": "Adicionados recentemente", diff --git a/i18n/ro.json b/i18n/ro.json index b9b04b7cce..b36ae22d36 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -272,7 +272,7 @@ "oauth_auto_register": "Auto ÃŽnregistrare", "oauth_auto_register_description": "Înregistrează automat utilizatori noi după autentificarea cu OAuth", "oauth_button_text": "Text buton", - "oauth_client_secret_description": "Necesar dacă PKCE (Proof Key for Code Exchange) nu este suportat de furnizorul OAuth", + "oauth_client_secret_description": "Necesar pentru un client confidențial sau dacă PKCE (Proof Key for Code Exchange) nu este suportat pentru un client public.", "oauth_enable_description": "Autentifică-te cu OAuth", "oauth_mobile_redirect_uri": "URI de redirecționare mobilă", "oauth_mobile_redirect_uri_override": "Înlocuire URI de redirecționare mobilă", @@ -513,7 +513,7 @@ "albums_default_sort_order_description": "Ordinea inițială de sortare a pozelor la crearea de albume noi.", "albums_feature_description": "Colecții de date care pot fi partajate cu alți utilizatori.", "albums_on_device_count": "{count} albume pe dispozitiv", - "albums_selected": "{număra, plural, unul {# album selectat} altele {# albumuri selectate}}", + "albums_selected": "{număr, plural, unul {# album selectat} altele {# albumuri selectate}}", "all": "Toate", "all_albums": "Toate albumele", "all_people": "Toți oamenii", @@ -626,7 +626,7 @@ "backup_album_selection_page_select_albums": "Selectează albume", "backup_album_selection_page_selection_info": "Informații selecție", "backup_album_selection_page_total_assets": "Total resurse unice", - "backup_albums_sync": "Sincronizarea albumelor de backup", + "backup_albums_sync": "Sincronizarea albumelor de rezervă", "backup_all": "Toate", "backup_background_service_backup_failed_message": "Eșuare backup resurse. ReÃŽncercareâ€Ļ", "backup_background_service_complete_notification": "Backup resurse finalizat", @@ -766,9 +766,9 @@ "cleanup_found_assets": "Am găsit {count} materiale in copia de rezerva", "cleanup_found_assets_with_size": "{count} obiecte găsite ({size})", "cleanup_icloud_shared_albums_excluded": "Albumele partajate iCLoud sunt excluse de la cautare", - "cleanup_no_assets_found": "Nici un material in copia de rezerva găsit după criteriu", + "cleanup_no_assets_found": "Nu au fost găsite fișiere care să corespundă criteriilor de mai sus. „Eliberare spațiu” poate șterge doar fișierele care au fost deja salvate pe server.", "cleanup_preview_title": "Materiale sa fie șterse ({count})", - "cleanup_step3_description": "Scanați pentru fotografii și videoclipuri pentru care au fost făcute copii de rezervă pe server cu data limită selectată și opțiunile de filtrare", + "cleanup_step3_description": "Scanează fișierele salvate pe server care corespund setărilor tale de dată și păstrare.", "cleanup_step4_summary": "{count} elemente create ÃŽnainte de {date} sunt puse ÃŽn coadă pentru a fi eliminate de pe dispozitiv", "cleanup_trash_hint": "Pentru a recupera complet spațiu de stocare, deschideți aplicația Galerie și goliți coșul de gunoi", "clear": "Curățați", @@ -782,6 +782,8 @@ "client_cert_import": "Importă", "client_cert_import_success_msg": "Certificatul de client este importat", "client_cert_invalid_msg": "Fisier cu certificat invalid sau parola este greșită", + "client_cert_password_message": "Introduceți parola pentru acest certificat", + "client_cert_password_title": "Parola certificatului", "client_cert_remove_msg": "Certificatul de client este șters", "client_cert_subtitle": "Este suportat doar formatul PKCS12 (.p12, .pfx). Importul/ștergerea certificatului este disponibil(ă) doar ÃŽnainte de autentificare", "client_cert_title": "Certificat SSL pentru client [EXPERIMENTAL]", @@ -867,8 +869,8 @@ "custom_locale": "Setare Regională Personalizată", "custom_locale_description": "Formatați datele și numerele ÃŽn funcție de limbă și regiune", "custom_url": "URL personalizat", - "cutoff_date_description": "Eliminați fotografiile și videoclipurile mai vechi de", - "cutoff_day": "{count, plural, o {day} mai multe {days}}", + "cutoff_date_description": "Păstrează fotografiile din ultimeleâ€Ļ", + "cutoff_day": "{număr, plural, o {day} mai multe {days}}", "cutoff_year": "{count, plural, =0 {0 ani} one {# an} few {# ani} other {# de ani}}", "daily_title_text_date": "E, LLL zz", "daily_title_text_date_year": "E, LLL zz, aaaa", @@ -995,6 +997,11 @@ "editor_close_without_save_prompt": "Schimbările nu vor fi salvate", "editor_close_without_save_title": "Închideți editorul?", "editor_confirm_reset_all_changes": "Sigur vrei să resetezi toate modificările?", + "editor_discard_edits_confirm": "Renunță modificările", + "editor_discard_edits_prompt": "Ai modificări nesalvate. Ești sigur că vrei să le renunți?", + "editor_discard_edits_title": "Renunți la modificări?", + "editor_edits_applied_error": "Nu s-au putut aplica modificările", + "editor_edits_applied_success": "Modificările au fost aplicate cu succes", "editor_flip_horizontal": "Întoarceți orizontal", "editor_flip_vertical": "Întoarceți vertical", "editor_orientation": "Orientare", @@ -1196,6 +1203,8 @@ "features_in_development": "Funcții ÃŽn dezvoltare", "features_setting_description": "Gestionați funcțiile aplicației", "file_name_or_extension": "Numele sau extensia fișierului", + "file_name_text": "Nume fișier", + "file_name_with_value": "Nume fișier: {file_name}", "file_size": "Mărime fișier", "filename": "Numele fișierului", "filetype": "Tipul fișierului", @@ -1214,7 +1223,7 @@ "forgot_pin_code_question": "Ai uitat codul PIN?", "forward": "Redirecționare", "free_up_space": "Eliberați spațiu", - "free_up_space_description": "Mută fotografiile și videoclipurile salvate ÃŽn coșul de gunoi al dispozitivului pentru a elibera spațiu. Copiile tale de pe server rămÃĸn ÃŽn siguranță", + "free_up_space_description": "Mută fotografiile și videoclipurile salvate ÃŽn coșul de gunoi al dispozitivului pentru a elibera spațiu. Copiile tale de pe server rămÃĸn ÃŽn siguranță.", "free_up_space_settings_subtitle": "Eliberați spațiul de stocare al dispozitivului", "full_path": "Calea completă: {path}", "gcast_enabled": "Google Cast", @@ -1574,7 +1583,7 @@ "no_albums_with_name_yet": "Se pare că nu aveți ÃŽncă niciun album cu acest nume.", "no_albums_yet": "Se pare că nu aveți ÃŽncă niciun album.", "no_archived_assets_message": "Arhivați fotografii și videoclipuri pentru a le ascunde din vizualizarea fotografii", - "no_assets_message": "CLICK PENTRU A ÎNCĂRCA PRIMA TA FOTOGRAFIE", + "no_assets_message": "Apasă pentru a ÃŽncărca prima ta fotografie.", "no_assets_to_show": "Nicio resursă de afișat", "no_cast_devices_found": "Nu s-au găsit dispozitive de difuzare", "no_checksum_local": "Nu există checksum – nu se pot prelua resursele locale", @@ -1604,7 +1613,6 @@ "not_available": "N/A", "not_in_any_album": "Nu există ÃŽn niciun album", "not_selected": "Neselectat", - "note_apply_storage_label_to_previously_uploaded assets": "Notă: Pentru a aplica eticheta de stocare la resursele ÃŽncărcate anterior, rulați", "notes": "Note", "nothing_here_yet": "Nimic aici ÃŽncă", "notification_permission_dialog_content": "Pentru a activa notificările, mergi ÃŽn Setări > Immich și selectează permite.", @@ -1806,7 +1814,7 @@ "reassigned_assets_to_new_person": "Re-alocat {count, plural, one {# resursă} other {# resurse}} unei noi persoane", "reassing_hint": "Atribuiți resursele selectate unei persoane existente", "recent": "Recent", - "recent-albums": "Albume recente", + "recent_albums": "Albume recente", "recent_searches": "Căutări recente", "recently_added": "Adăugate recent", "recently_added_page_title": "Adăugate recent", @@ -2251,7 +2259,7 @@ "trigger_asset_uploaded": "Fișier ÃŽncărcat", "trigger_asset_uploaded_description": "Declanșează cand un fișier este ÃŽncarcat", "trigger_description": "Un eveniment care declanșează fluxul de lucru", - "trigger_person_recognized": "Persoana Recunoscută", + "trigger_person_recognized": "Persoană Recunoscută", "trigger_person_recognized_description": "Declanșat atunci cÃĸnd este detectată o persoană", "trigger_type": "Tip de declanșare", "troubleshoot": "Depanați", @@ -2287,7 +2295,7 @@ "unstacked_assets_count": "Nestivuit {count, plural, one {# resursă} other {# resurse}}", "unsupported_field_type": "Tip de cÃĸmp neacceptat", "untagged": "Neetichetat", - "untitled_workflow": "Flux fara titlu", + "untitled_workflow": "Flux de lucru fără titlu", "up_next": "Mai departe", "update_location_action_prompt": "Actualizează locația pentru {count} resurse selectate cu:", "updated_at": "Actualizat", @@ -2297,7 +2305,7 @@ "upload_details": "Detalii ÃŽncărcare", "upload_dialog_info": "Vrei să backup resursele selectate pe server?", "upload_dialog_title": "Încarcă resursă", - "upload_error_with_count": "Eroare la ÃŽncărcare pentru {count, plural, one {# fișier} other {# fișiere}}", + "upload_error_with_count": "Eroare la ÃŽncărcare pentru {număr, plural, un {# fișier} alte {# fișiere}}", "upload_errors": "Încărcare finalizată cu {count, plural, one {# eroare} other {# erori}}, reÃŽmprospătați pagina pentru a reÃŽncărca noile resurse.", "upload_finished": "Încărcarea s-a finalizat", "upload_progress": "Rămas {remaining, number} - Procesat {processed, number}/{total, number}", @@ -2312,7 +2320,7 @@ "url": "URL", "usage": "Utilizare", "use_biometric": "Folosește biometrice", - "use_current_connection": "folosește conexiunea curentă", + "use_current_connection": "Folosește conexiunea curentă", "use_custom_date_range": "Utilizați ÃŽn schimb un interval de date personalizat", "user": "Utilizator", "user_has_been_deleted": "Acest utilizator a fost șters.", diff --git a/i18n/ru.json b/i18n/ru.json index e7ef99ea62..a1e542a2e5 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -311,7 +311,7 @@ "search_jobs": "ĐŸĐžĐ¸ŅĐē ĐˇĐ°Đ´Đ°Ņ‡â€Ļ", "send_welcome_email": "ĐžŅ‚ĐŋŅ€Đ°Đ˛Đ¸Ņ‚ŅŒ ĐŋŅ€Đ¸Đ˛ĐĩŅ‚ŅŅ‚Đ˛ĐĩĐŊĐŊĐžĐĩ ĐŋĐ¸ŅŅŒĐŧĐž", "server_external_domain_settings": "ВĐŊĐĩ҈ĐŊиК Đ´ĐžĐŧĐĩĐŊ", - "server_external_domain_settings_description": "ДоĐŧĐĩĐŊ Đ´ĐģŅ ĐŋŅƒĐąĐģĐ¸Ņ‡ĐŊҋ҅ ҁҁҋĐģĐžĐē, вĐēĐģŅŽŅ‡Đ°Ņ http(s)://", + "server_external_domain_settings_description": "ДоĐŧĐĩĐŊ Đ´ĐģŅ ĐŋŅƒĐąĐģĐ¸Ņ‡ĐŊҋ҅ ҁҁҋĐģĐžĐē", "server_public_users": "ĐŸŅƒĐąĐģĐ¸Ņ‡ĐŊŅ‹Đĩ ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģи", "server_public_users_description": "Đ’Ņ‹Đ˛ĐžĐ´Đ¸Ņ‚ŅŒ ҁĐŋĐ¸ŅĐžĐē ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģĐĩĐš (иĐŧĐĩĐŊа и email) в ĐžĐąŅ‰Đ¸Ņ… аĐģŅŒĐąĐžĐŧĐ°Ņ…. ĐšĐžĐŗĐ´Đ° ĐžŅ‚ĐēĐģŅŽŅ‡ĐĩĐŊĐž, ҁĐŋĐ¸ŅĐžĐē Đ´ĐžŅŅ‚ŅƒĐŋĐĩĐŊ Ņ‚ĐžĐģҌĐēĐž адĐŧиĐŊĐ¸ŅŅ‚Ņ€Đ°Ņ‚ĐžŅ€Đ°Đŧ, ĐŋĐžĐģŅŒĐˇĐžĐ˛Đ°Ņ‚ĐĩĐģи ҁĐŧĐžĐŗŅƒŅ‚ Đ´ĐĩĐģĐ¸Ņ‚ŅŒŅŅ Ņ‚ĐžĐģҌĐēĐž ҁҁҋĐģĐēОК.", "server_settings": "ĐĐ°ŅŅ‚Ņ€ĐžĐšĐēи ҁĐĩŅ€Đ˛ĐĩŅ€Đ°", @@ -794,6 +794,11 @@ "color": "ĐĻвĐĩŅ‚", "color_theme": "ĐĻвĐĩŅ‚ĐžĐ˛Đ°Ņ Ņ‚ĐĩĐŧа", "command": "КоĐŧаĐŊда", + "command_palette_prompt": "Đ‘Ņ‹ŅŅ‚Ņ€Ņ‹Đš ĐŋĐžĐ¸ŅĐē ŅŅ‚Ņ€Đ°ĐŊĐ¸Ņ†, Đ´ĐĩĐšŅŅ‚Đ˛Đ¸Đš иĐģи ĐēĐžĐŧаĐŊĐ´", + "command_palette_to_close": "СаĐēŅ€Ņ‹Ņ‚ŅŒ", + "command_palette_to_navigate": "ĐŊĐ°Đ˛Đ¸ĐŗĐ°Ņ†Đ¸Ņ", + "command_palette_to_select": "Đ˛Ņ‹ĐąŅ€Đ°Ņ‚ŅŒ", + "command_palette_to_show_all": "ĐŋĐžĐēĐ°ĐˇĐ°Ņ‚ŅŒ Đ˛ŅĐĩ", "comment_deleted": "КоĐŧĐŧĐĩĐŊŅ‚Đ°Ņ€Đ¸Đš ŅƒĐ´Đ°ĐģŅ‘ĐŊ", "comment_options": "ДĐĩĐšŅŅ‚Đ˛Đ¸Ņ ҁ ĐēĐžĐŧĐŧĐĩĐŊŅ‚Đ°Ņ€Đ¸ĐĩĐŧ", "comments_and_likes": "КоĐŧĐŧĐĩĐŊŅ‚Đ°Ņ€Đ¸Đ¸ и ĐžŅ‚ĐŧĐĩŅ‚Đēи \"ĐŊŅ€Đ°Đ˛Đ¸Ņ‚ŅŅ\"", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "ЛЮДИ", "exif_bottom_sheet_person_add_person": "Đ”ĐžĐąĐ°Đ˛Đ¸Ņ‚ŅŒ иĐŧŅ", "exit_slideshow": "Đ’Ņ‹ĐšŅ‚Đ¸ иС ҁĐģаКд-ŅˆĐžŅƒ", + "expand": "РаСвĐĩŅ€ĐŊŅƒŅ‚ŅŒ", "expand_all": "РаСвĐĩŅ€ĐŊŅƒŅ‚ŅŒ Đ˛ŅŅ‘", "experimental_settings_new_asset_list_subtitle": "В Ņ€Đ°ĐˇŅ€Đ°ĐąĐžŅ‚ĐēĐĩ", "experimental_settings_new_asset_list_title": "ВĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅĐēҁĐŋĐĩŅ€Đ¸ĐŧĐĩĐŊŅ‚Đ°ĐģҌĐŊŅƒŅŽ ҁĐĩŅ‚Đē҃ Ņ„ĐžŅ‚ĐžĐŗŅ€Đ°Ņ„Đ¸Đš", @@ -1613,7 +1619,6 @@ "not_available": "НĐĩŅ‚ даĐŊĐŊҋ҅", "not_in_any_album": "Ни в ОдĐŊĐžĐŧ аĐģŅŒĐąĐžĐŧĐĩ", "not_selected": "НĐĩ Đ˛Ņ‹ĐąŅ€Đ°ĐŊĐž", - "note_apply_storage_label_to_previously_uploaded assets": "ĐŸŅ€Đ¸ĐŧĐĩŅ‡Đ°ĐŊиĐĩ: Đ§Ņ‚ĐžĐąŅ‹ ĐŋŅ€Đ¸ĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŧĐĩŅ‚Đē҃ Ņ…Ņ€Đ°ĐŊиĐģĐ¸Ņ‰Đ° Đē Ņ€Đ°ĐŊĐĩĐĩ ĐˇĐ°ĐŗŅ€ŅƒĐļĐĩĐŊĐŊŅ‹Đŧ ĐžĐąŅŠĐĩĐēŅ‚Đ°Đŧ, СаĐŋŅƒŅŅ‚Đ¸Ņ‚Đĩ", "notes": "ĐŸŅ€Đ¸ĐŧĐĩŅ‡Đ°ĐŊиĐĩ", "nothing_here_yet": "ЗдĐĩҁҌ ĐŋĐžĐēа ĐŊĐ¸Ņ‡ĐĩĐŗĐž ĐŊĐĩŅ‚", "notification_permission_dialog_content": "Đ§Ņ‚ĐžĐąŅ‹ вĐēĐģŅŽŅ‡Đ¸Ņ‚ŅŒ ŅƒĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊĐ¸Ņ, ĐŋĐĩŅ€ĐĩĐšĐ´Đ¸Ņ‚Đĩ в ÂĢĐĐ°ŅŅ‚Ņ€ĐžĐšĐēиÂģ и Đ˛Ņ‹ĐąĐĩŅ€Đ¸Ņ‚Đĩ ÂĢĐ Đ°ĐˇŅ€ĐĩŅˆĐ¸Ņ‚ŅŒÂģ.", @@ -1643,6 +1648,7 @@ "online": "Đ”ĐžŅŅ‚ŅƒĐŋĐĩĐŊ", "only_favorites": "ĐĸĐžĐģҌĐēĐž Đ¸ĐˇĐąŅ€Đ°ĐŊĐŊĐžĐĩ", "open": "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ", + "open_calendar": "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ ĐēаĐģĐĩĐŊĐ´Đ°Ņ€ŅŒ", "open_in_map_view": "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ в Ņ€ĐĩĐļиĐŧĐĩ ĐŋŅ€ĐžŅĐŧĐžŅ‚Ņ€Đ° ĐēĐ°Ņ€Ņ‚Ņ‹", "open_in_openstreetmap": "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ в OpenStreetMap", "open_the_search_filters": "ĐžŅ‚ĐēŅ€Ņ‹Ņ‚ŅŒ Ņ„Đ¸ĐģŅŒŅ‚Ņ€Ņ‹ ĐŋĐžĐ¸ŅĐēа", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "Đ›Đ¸Ņ†Đ° ĐŊа {count, plural, one {# ĐžĐąŅŠĐĩĐēŅ‚Đĩ} other {# ĐžĐąŅŠĐĩĐēŅ‚Đ°Ņ…}} ĐŋĐĩŅ€ĐĩĐŊаСĐŊĐ°Ņ‡ĐĩĐŊŅ‹ ĐŊа ĐŊĐžĐ˛ĐžĐŗĐž ҇ĐĩĐģОвĐĩĐēа", "reassing_hint": "НазĐŊĐ°Ņ‡Đ¸Ņ‚ŅŒ Đ˛Ņ‹ĐąŅ€Đ°ĐŊĐŊŅ‹Đĩ ĐžĐąŅŠĐĩĐē҂ҋ ҃ĐēаСаĐŊĐŊĐžĐŧ҃ ҇ĐĩĐģОвĐĩĐē҃", "recent": "НĐĩдавĐŊиĐĩ", - "recent-albums": "НĐĩдавĐŊиĐĩ аĐģŅŒĐąĐžĐŧŅ‹", + "recent_albums": "НĐĩдавĐŊиĐĩ аĐģŅŒĐąĐžĐŧŅ‹", "recent_searches": "НĐĩдавĐŊиĐĩ ĐŋĐžĐ¸ŅĐēĐžĐ˛Ņ‹Đĩ СаĐŋŅ€ĐžŅŅ‹", "recently_added": "НĐĩдавĐŊĐž дОйавĐģĐĩĐŊĐŊŅ‹Đĩ", "recently_added_page_title": "НĐĩдавĐŊĐž дОйавĐģĐĩĐŊĐŊŅ‹Đĩ", @@ -2129,7 +2135,7 @@ "show_search_options": "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ ĐŋĐ°Ņ€Đ°ĐŧĐĩ҂Ҁҋ ĐŋĐžĐ¸ŅĐēа", "show_shared_links": "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ ĐŋŅƒĐąĐģĐ¸Ņ‡ĐŊŅ‹Đĩ ҁҁҋĐģĐēи", "show_slideshow_transition": "ПĐģавĐŊŅ‹Đš ĐŋĐĩŅ€ĐĩŅ…ĐžĐ´", - "show_supporter_badge": "ЗĐŊĐ°Ņ‡ĐžĐē ĐŋОддĐĩŅ€ĐļĐēи", + "show_supporter_badge": "ЗĐŊĐ°Ņ‡ĐžĐē ҁĐŋĐžĐŊŅĐžŅ€ŅŅ‚Đ˛Đ°", "show_supporter_badge_description": "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ СĐŊĐ°Ņ‡ĐžĐē ĐŋОддĐĩŅ€ĐļĐēи", "show_text_recognition": "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ Ņ€Đ°ŅĐŋОСĐŊаĐŊĐŊŅ‹Đš Ņ‚ĐĩĐēҁ҂", "show_text_search_menu": "ПоĐēĐ°ĐˇĐ°Ņ‚ŅŒ ĐŧĐĩĐŊŅŽ Ņ‚ĐĩĐēŅŅ‚ĐžĐ˛ĐžĐŗĐž ĐŋĐžĐ¸ŅĐēа", @@ -2184,6 +2190,7 @@ "support": "ПоддĐĩŅ€ĐļĐēа", "support_and_feedback": "ПоддĐĩŅ€ĐļĐēа и ĐžĐąŅ€Đ°Ņ‚ĐŊĐ°Ņ ŅĐ˛ŅĐˇŅŒ", "support_third_party_description": "Đ’Đ°ŅˆĐ° ŅƒŅŅ‚Đ°ĐŊОвĐēа immich ĐąŅ‹Đģа ҃ĐŋаĐēОваĐŊа ŅŅ‚ĐžŅ€ĐžĐŊĐŊиĐŧ Ņ€Đ°ĐˇŅ€Đ°ĐąĐžŅ‚Ņ‡Đ¸ĐēĐžĐŧ. ĐŸŅ€ĐžĐąĐģĐĩĐŧŅ‹, ҁ ĐēĐžŅ‚ĐžŅ€Ņ‹Đŧи Đ˛Ņ‹ ŅŅ‚ĐžĐģĐēĐŊ҃ĐģĐ¸ŅŅŒ, ĐŧĐžĐŗŅƒŅ‚ ĐąŅ‹Ņ‚ŅŒ Đ˛Ņ‹ĐˇĐ˛Đ°ĐŊŅ‹ ŅŅ‚Đ¸Đŧ ĐŋаĐēĐĩŅ‚ĐžĐŧ, ĐŋĐžŅŅ‚ĐžĐŧ҃, ĐŋĐžĐļаĐģŅƒĐšŅŅ‚Đ°, в ĐŋĐĩŅ€Đ˛ŅƒŅŽ ĐžŅ‡ĐĩŅ€ĐĩĐ´ŅŒ ĐžĐąŅ€Đ°Ņ‰Đ°ĐšŅ‚ĐĩҁҌ Đē ĐŊиĐŧ, Đ¸ŅĐŋĐžĐģŅŒĐˇŅƒŅ ҁҁҋĐģĐēи ĐŊиĐļĐĩ.", + "supporter": "ĐĄĐŋĐžĐŊŅĐžŅ€ Immich", "swap_merge_direction": "ИСĐŧĐĩĐŊĐ¸Ņ‚ŅŒ ĐŊаĐŋŅ€Đ°Đ˛ĐģĐĩĐŊиĐĩ ҁĐģĐ¸ŅĐŊĐ¸Ņ", "sync": "ХиĐŊŅ…Ņ€.", "sync_albums": "ХиĐŊŅ…Ņ€ĐžĐŊĐ¸ĐˇĐ¸Ņ€ĐžĐ˛Đ°Ņ‚ŅŒ аĐģŅŒĐąĐžĐŧŅ‹", diff --git a/i18n/sk.json b/i18n/sk.json index 6027198492..c8c3c69802 100644 --- a/i18n/sk.json +++ b/i18n/sk.json @@ -311,7 +311,7 @@ "search_jobs": "VyhÄžadaÅĨ Ãēlohyâ€Ļ", "send_welcome_email": "OdoslaÅĨ uvítací e-mail", "server_external_domain_settings": "ExternÃĄ domÊna", - "server_external_domain_settings_description": "VerejnÃĄ domÊna pre zdieÄžanÊ odkazy, vrÃĄtane http(s)://", + "server_external_domain_settings_description": "DomÊna pouŞívanÃĄ pre externÊ odkazy", "server_public_users": "Verejní pouŞívatelia", "server_public_users_description": "VÅĄetci pouŞívatelia (meno a email) sÃē uvedení pri pridÃĄvaní pouŞívateÄža do zdieÄžanÃŊch albumov. Ak je tÃĄto funkcia vypnutÃĄ, zoznam pouŞívateÄžov bude dostupnÃŊ iba sprÃĄvcom.", "server_settings": "Server", @@ -794,6 +794,11 @@ "color": "Farba", "color_theme": "Farba tÊmy", "command": "Príkaz", + "command_palette_prompt": "RÃŊchlo vyhÄžadajte strÃĄnky, akcie alebo príkazy ako", + "command_palette_to_close": "zatvoriÅĨ", + "command_palette_to_navigate": "vloÅžiÅĨ", + "command_palette_to_select": "vybraÅĨ", + "command_palette_to_show_all": "zobraziÅĨ vÅĄetko", "comment_deleted": "KomentÃĄr bol odstrÃĄnenÃŊ", "comment_options": "MoÅžnosti komentÃĄra", "comments_and_likes": "KomentÃĄre a pÃĄÄi sa mi to", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "ÄŊUDIA", "exif_bottom_sheet_person_add_person": "PridaÅĨ meno", "exit_slideshow": "OpustiÅĨ prezentÃĄciu", + "expand": "RozbaliÅĨ", "expand_all": "RozbaliÅĨ vÅĄetko", "experimental_settings_new_asset_list_subtitle": "PrebiehajÃēca prÃĄca", "experimental_settings_new_asset_list_title": "Povolenie experimentÃĄlnej mrieÅžky fotografií", @@ -1613,7 +1619,6 @@ "not_available": "NedostupnÊ", "not_in_any_album": "Nie je v Åžiadnom albume", "not_selected": "NevybranÊ", - "note_apply_storage_label_to_previously_uploaded assets": "PoznÃĄmka: Ak chcete pouÅžiÅĨ Å títok ÃēloÅžiska na predtÃŊm nahranÊ mÊdiÃĄ, spustite príkaz", "notes": "PoznÃĄmky", "nothing_here_yet": "ZatiaÄž tu nič nie je", "notification_permission_dialog_content": "Ak chcete povoliÅĨ upozornenia, prejdite do Nastavenia a vyberte moÅžnosÅĨ PovoliÅĨ.", @@ -1643,6 +1648,7 @@ "online": "Online", "only_favorites": "Len obÄžÃēbenÊ", "open": "OtvoriÅĨ", + "open_calendar": "OtvoriÅĨ kalendÃĄr", "open_in_map_view": "OtvoriÅĨ v mape", "open_in_openstreetmap": "OtvoriÅĨ v OpenStreetMap", "open_the_search_filters": "OtvoriÅĨ vyhÄžadÃĄvacie filtre", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "Opätovne {count, plural, one {priradenÃĄ # poloÅžka} few {priradenÊ # poloÅžky} other {priradenÃŊch # poloÅžiek}} novej osobe", "reassing_hint": "Priradí zvolenÃē poloÅžku k existujÃēcej osobe", "recent": "NedÃĄvne", - "recent-albums": "PoslednÊ albumy", + "recent_albums": "PoslednÊ albumy", "recent_searches": "PoslednÊ vyhÄžadÃĄvania", "recently_added": "NedÃĄvno pridanÊ", "recently_added_page_title": "NedÃĄvno pridanÊ", @@ -2184,6 +2190,7 @@ "support": "Podpora", "support_and_feedback": "Podpora a spätnÃĄ väzba", "support_third_party_description": "VaÅĄa inÅĄtalÃĄcia Immich bola pripravenÃĄ treÅĨou stranou. ProblÊmy, ktorÊ sa vyskytli, môŞu byÅĨ spôsobenÊ tÃŊmto balíčkom, preto sa na nich obrÃĄÅĨte v prvom rade cez nasledujÃēce odkazy.", + "supporter": "PodporovateÄž", "swap_merge_direction": "VymeniÅĨ smer zlÃēčenia", "sync": "SynchronizovaÅĨ", "sync_albums": "SynchronizovaÅĨ albumy", diff --git a/i18n/sl.json b/i18n/sl.json index ca0736e3e3..b4f899bb4d 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -311,7 +311,7 @@ "search_jobs": "IÅĄÄi opravilaâ€Ļ", "send_welcome_email": "PoÅĄlji pozdravno e-poÅĄto", "server_external_domain_settings": "Zunanja domena", - "server_external_domain_settings_description": "Domena za javne skupne povezave, vključno s http(s)://", + "server_external_domain_settings_description": "Domena, uporabljena za zunanje povezave", "server_public_users": "Javni uporabniki", "server_public_users_description": "Vsi uporabniki (ime in e-poÅĄta) so navedeni pri dodajanju uporabnika v albume v skupni rabi. Ko je onemogočen, bo seznam uporabnikov na voljo samo skrbniÅĄkim uporabnikom.", "server_settings": "Nastavitve streÅžnika", @@ -794,6 +794,11 @@ "color": "Barva", "color_theme": "Barva teme", "command": "Ukaz", + "command_palette_prompt": "Hitro iskanje strani, dejanj ali ukazov", + "command_palette_to_close": "zapreti", + "command_palette_to_navigate": "vstopiti", + "command_palette_to_select": "izbrati", + "command_palette_to_show_all": "prikazati vse", "comment_deleted": "Komentar izbrisan", "comment_options": "MoÅžnosti komentiranja", "comments_and_likes": "Komentarji in vÅĄečki", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "OSEBE", "exif_bottom_sheet_person_add_person": "Dodaj ime", "exit_slideshow": "Zapustite diaprojekcijo", + "expand": "RazÅĄiri", "expand_all": "RazÅĄiri vse", "experimental_settings_new_asset_list_subtitle": "Delo v teku", "experimental_settings_new_asset_list_title": "Omogoči eksperimentalno mreÅžo fotografij", @@ -1613,7 +1619,6 @@ "not_available": "Ni na voljo", "not_in_any_album": "Ni v nobenem albumu", "not_selected": "Ni izbrano", - "note_apply_storage_label_to_previously_uploaded assets": "Opomba: Če Åželite oznako za shranjevanje uporabiti za predhodno naloÅžena sredstva, zaÅženite", "notes": "Opombe", "nothing_here_yet": "Tukaj ÅĄe ni ničesar", "notification_permission_dialog_content": "Če Åželite omogočiti obvestila, pojdite v Nastavitve in izberite Dovoli.", @@ -1643,6 +1648,7 @@ "online": "Povezano", "only_favorites": "Samo priljubljene", "open": "Odpri", + "open_calendar": "Odpri koledar", "open_in_map_view": "Odpri v pogledu zemljevida", "open_in_openstreetmap": "Odpri v OpenStreetMap", "open_the_search_filters": "Odpri iskalne filtre", @@ -1815,7 +1821,7 @@ "reassigned_assets_to_new_person": "Ponovno dodeljeno {count, plural, one {# sredstvo} two {# sredstvi} few {# sredstva} other {# sredstev}} za novo osebo", "reassing_hint": "Dodeli izbrana sredstva obstoječi osebi", "recent": "Nedavno", - "recent-albums": "Zadnji albumi", + "recent_albums": "Zadnji albumi", "recent_searches": "Nedavna iskanja", "recently_added": "Nedavno dodano", "recently_added_page_title": "Nedavno dodano", @@ -2184,6 +2190,7 @@ "support": "Podpora", "support_and_feedback": "Podpora in povratne informacije", "support_third_party_description": "VaÅĄo namestitev Immich je pakirala tretja oseba. TeÅžave, ki jih imate, lahko povzroči ta paket, zato prosimo, da teÅžave najprej izpostavite njim, tako da uporabite spodnje povezave.", + "supporter": "Podpornik", "swap_merge_direction": "Zamenjaj smer zdruÅževanja", "sync": "Sinhronizacija", "sync_albums": "Sinhronizacija albumov", diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index d656ac248e..ad4dd8ecca 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -1280,7 +1280,6 @@ "not_available": "НĐĩĐ´ĐžŅŅ‚ŅƒĐŋĐŊĐž", "not_in_any_album": "НĐĩĐŧа ĐŊи ҃ ҘĐĩĐ´ĐŊĐžĐŧ аĐģĐąŅƒĐŧ҃", "not_selected": "ĐĐ¸Ņ˜Đĩ Đ¸ĐˇĐ°ĐąŅ€Đ°ĐŊĐž", - "note_apply_storage_label_to_previously_uploaded assets": "НаĐŋĐžĐŧĐĩĐŊа: Да ĐąĐ¸ŅŅ‚Đĩ ĐŋŅ€Đ¸ĐŧĐĩĐŊиĐģи ОСĐŊаĐē҃ Са ҁĐēĐģĐ°Đ´Đ¸ŅˆŅ‚ĐĩҚĐĩ ĐŊа ĐŋŅ€ĐĩŅ‚Ņ…ĐžĐ´ĐŊĐž ҃ĐŋĐģĐžĐ°Đ´Đ¸Ņ€Đ°ĐŊĐĩ Đ´Đ°Ņ‚ĐžŅ‚ĐĩĐēĐĩ, ĐŋĐžĐēŅ€ĐĩĐŊĐ¸Ņ‚Đĩ", "notes": "НаĐŋĐžĐŧĐĩĐŊĐĩ", "notification_permission_dialog_content": "Да йи ҃ĐēŅ™ŅƒŅ†Đ¸Đģи ĐŊĐžŅ‚Đ¸Ņ„Đ¸ĐēĐ°Ņ†Đ¸Ņ˜Đĩ, Đ¸Đ´Đ¸Ņ‚Đĩ ҃ ОĐŋŅ†Đ¸Ņ˜Đĩ и ОдайĐĩŅ€Đ¸Ņ‚Đĩ ДозвоĐģи.", "notification_permission_list_tile_content": "Đ”Đ°Ņ˜Ņ‚Đĩ дОСвОĐģ҃ Са ĐžĐŧĐžĐŗŅƒŅ›Đ°Đ˛Đ°ŅšĐĩ ОйавĐĩŅˆŅ‚ĐĩŅšĐ°.", @@ -1448,7 +1447,7 @@ "reassigned_assets_to_new_person": "ПоĐŊОвО дОдĐĩŅ™ĐĩĐŊĐž {count, plural, one {# Đ´Đ°Ņ‚ĐžŅ‚ĐĩĐēа} other {# Đ´Đ°Ņ‚ĐžŅ‚ĐĩĐēĐĩ}} ĐŊĐžĐ˛ĐžŅ˜ ĐžŅĐžĐąĐ¸", "reassing_hint": "ДодĐĩĐģĐ¸Ņ‚Đĩ Đ¸ĐˇĐ°ĐąŅ€Đ°ĐŊа ҁҀĐĩĐ´ŅŅ‚Đ˛Đ° ĐŋĐžŅŅ‚ĐžŅ˜ĐĩŅ›ĐžŅ˜ ĐžŅĐžĐąĐ¸", "recent": "ĐĄĐēĐžŅ€Đ°ŅˆŅšĐ¸", - "recent-albums": "НĐĩдавĐŊи аĐģĐąŅƒĐŧи", + "recent_albums": "НĐĩдавĐŊи аĐģĐąŅƒĐŧи", "recent_searches": "ĐĄĐēĐžŅ€Đ°ŅˆŅšĐĩ ĐŋŅ€ĐĩŅ‚Ņ€Đ°ĐŗĐĩ", "recently_added": "НĐĩдавĐŊĐž Đ´ĐžĐ´Đ°Ņ‚Đž", "recently_added_page_title": "НĐĩдавĐŊĐž Đ”ĐžĐ´Đ°Ņ‚Đž", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index b6f36d8c70..b59f86f37c 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -1241,7 +1241,6 @@ "no_shared_albums_message": "Napravite album da biste delili fotografije i video zapise sa ljudima u vaÅĄoj mreÅži", "not_in_any_album": "Nema ni u jednom albumu", "not_selected": "Nije izabrano", - "note_apply_storage_label_to_previously_uploaded assets": "Napomena: Da biste primenili oznaku za skladiÅĄtenje na prethodno uploadirane datoteke, pokrenite", "notes": "Napomene", "notification_permission_dialog_content": "Da bi ukljucili notifikacije, idite u Opcije i odaberite Dozvoli.", "notification_permission_list_tile_content": "Dajte dozvolu za omogucˁavanje obaveÅĄtenja.", @@ -1399,7 +1398,7 @@ "reassigned_assets_to_new_person": "Ponovo dodeljeno {count, plural, one {# datoteka} other {# datoteke}} novoj osobi", "reassing_hint": "Dodelite izabrana sredstva postojecˁoj osobi", "recent": "SkoraÅĄnji", - "recent-albums": "Nedavni albumi", + "recent_albums": "Nedavni albumi", "recent_searches": "SkoraÅĄnje pretrage", "recently_added": "Nedavno dodato", "recently_added_page_title": "Nedavno Dodato", diff --git a/i18n/sv.json b/i18n/sv.json index 821f918aad..ddc6a1b336 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -443,7 +443,7 @@ "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", + "version_check_settings_description": "Aktivera/inaktivera notis om ny version", "video_conversion_job": "Omkoda videor", "video_conversion_job_description": "Koda om videor fÃļr bredare kompatibilitet med webbläsare och enheter" }, @@ -1613,7 +1613,6 @@ "not_available": "N/A", "not_in_any_album": "Inte i nÃĨgot album", "not_selected": "Ej vald", - "note_apply_storage_label_to_previously_uploaded assets": "Obs: Om du vill använda lagringsetiketten pÃĨ tidigare uppladdade tillgÃĨngar kÃļr du", "notes": "Notera", "nothing_here_yet": "Inget här ännu", "notification_permission_dialog_content": "FÃļr att aktivera notiser, gÃĨ till Inställningar och välj tillÃĨt.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "Tilldelade om {count, plural, one {# objekt} other {# objekt}} till en ny persson", "reassing_hint": "Tilldela valda tillgÃĨngar till en befintlig person", "recent": "Nyligen", - "recent-albums": "Senaste album", + "recent_albums": "Senaste album", "recent_searches": "Senaste sÃļkningar", "recently_added": "Nyligen tillagda", "recently_added_page_title": "Nyligen tillagda", diff --git a/i18n/ta.json b/i18n/ta.json index e27bdfd0cb..90a7ce6664 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -18,6 +18,7 @@ "add_a_title": "āŽ¤āŽ˛ā¯ˆāŽĒā¯āŽĒ❁ āŽšā¯‡āŽ°ā¯āŽ•ā¯āŽ•āŽĩā¯āŽŽā¯", "add_action": "āŽšā¯†āŽ¯āŽ˛ā¯ˆāŽšā¯ āŽšā¯‡āŽ°ā¯", "add_action_description": "āŽšā¯†āŽ¯ā¯āŽ¯ āŽĩā¯‡āŽŖā¯āŽŸāŽŋāŽ¯ āŽšā¯†āŽ¯āŽ˛ā¯ˆāŽšā¯ āŽšā¯‡āŽ°ā¯āŽ•ā¯āŽ• āŽ•āŽŋāŽŗāŽŋāŽ•ā¯ āŽšā¯†āŽ¯ā¯āŽ¯āŽĩā¯āŽŽā¯", + "add_assets": "āŽŠāŽŸāŽ™ā¯āŽ•āŽŗā¯ˆ āŽšā¯‡āŽ°ā¯āŽ•ā¯āŽ•āŽĩā¯āŽŽā¯", "add_birthday": "āŽĒāŽŋāŽąāŽ¨ā¯āŽ¤āŽ¨āŽžāŽŗā¯ˆāŽšā¯ āŽšā¯‡āŽ°ā¯āŽ•ā¯āŽ•āŽĩā¯āŽŽā¯", "add_endpoint": "āŽšā¯‡āŽĩ❈ āŽ¨āŽŋāŽ°āŽ˛ā¯ˆ āŽšā¯‡āŽ°ā¯", "add_exclusion_pattern": "āŽĩāŽŋāŽ˛āŽ•ā¯āŽ•ā¯ āŽĩāŽŸāŽŋāŽĩāŽ¤ā¯āŽ¤ā¯ˆāŽšā¯ āŽšā¯‡āŽ°ā¯āŽ•ā¯āŽ•āŽĩā¯āŽŽā¯", @@ -187,6 +188,7 @@ "machine_learning_smart_search_enabled": "āŽ¸ā¯āŽŽāŽžāŽ°ā¯āŽŸā¯ āŽ¤ā¯‡āŽŸāŽ˛ā¯ˆ āŽ‡āŽ¯āŽ•ā¯āŽ•ā¯", "machine_learning_smart_search_enabled_description": "āŽŽā¯āŽŸāŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŸāŽŋāŽ°ā¯āŽ¨ā¯āŽ¤āŽžāŽ˛ā¯, āŽ¸ā¯āŽŽāŽžāŽ°ā¯āŽŸā¯ āŽ¤ā¯‡āŽŸāŽ˛ā¯āŽ•ā¯āŽ•āŽžāŽ• āŽĒāŽŸāŽ™ā¯āŽ•āŽŗā¯ āŽ•ā¯āŽąāŽŋāŽ¯āŽžāŽ•ā¯āŽ•āŽŽā¯ āŽšā¯†āŽ¯ā¯āŽ¯āŽĒā¯āŽĒāŽŸāŽžāŽ¤ā¯.", "machine_learning_url_description": "āŽ‡āŽ¯āŽ¨ā¯āŽ¤āŽŋāŽ° āŽ•āŽąā¯āŽąāŽ˛ā¯ āŽšā¯‡āŽĩā¯ˆāŽ¯āŽ•āŽ¤ā¯āŽ¤āŽŋāŽŠā¯ āŽŽā¯āŽ•āŽĩāŽ°āŽŋ. āŽ’āŽŠā¯āŽąā¯āŽ•ā¯āŽ•ā¯ āŽŽā¯‡āŽąā¯āŽĒāŽŸā¯āŽŸ āŽŽā¯āŽ•āŽĩāŽ°āŽŋ āŽĩāŽ´āŽ™ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŸāŽžāŽ˛ā¯, āŽ’āŽĩā¯āŽĩā¯ŠāŽ°ā¯ āŽšā¯‡āŽĩā¯ˆāŽ¯āŽ•āŽŽā¯āŽŽā¯ āŽ’āŽĩā¯āŽĩā¯ŠāŽŠā¯āŽąāŽžāŽ• āŽĩā¯†āŽąā¯āŽąāŽŋāŽ•āŽ°āŽŽāŽžāŽ• āŽĒāŽ¤āŽŋāŽ˛āŽŗāŽŋāŽ•ā¯āŽ•ā¯āŽŽā¯ āŽĩāŽ°ā¯ˆ, āŽŽā¯āŽ¤āŽ˛āŽŋāŽ˛ā¯ āŽ‡āŽ°ā¯āŽ¨ā¯āŽ¤ā¯ āŽ•āŽŸā¯ˆāŽšāŽŋ āŽĩāŽ°ā¯ˆ āŽŽā¯āŽ¯āŽąā¯āŽšāŽŋāŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŽā¯. āŽĒāŽ¤āŽŋāŽ˛āŽŗāŽŋāŽ•ā¯āŽ•āŽžāŽ¤ āŽšā¯‡āŽĩā¯ˆāŽ¯āŽ•āŽ™ā¯āŽ•āŽŗā¯ āŽŽā¯€āŽŖā¯āŽŸā¯āŽŽā¯ āŽ†āŽŠā¯āŽ˛ā¯ˆāŽŠāŽŋāŽ˛ā¯ āŽĩāŽ°ā¯āŽŽā¯ āŽĩāŽ°ā¯ˆ āŽ¤āŽąā¯āŽ•āŽžāŽ˛āŽŋāŽ•āŽŽāŽžāŽ•āŽĒā¯ āŽĒā¯āŽąāŽ•ā¯āŽ•āŽŖāŽŋāŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŽā¯.", + "maintenance_delete_backup": "āŽ•āŽžāŽĒā¯āŽĒā¯āŽ•ā¯āŽ•āŽŗā¯ˆ āŽ¨ā¯€āŽ•ā¯āŽ•āŽĩā¯āŽŽā¯", "maintenance_settings": "āŽĒāŽ°āŽžāŽŽāŽ°āŽŋāŽĒā¯āŽĒ❁", "maintenance_settings_description": "āŽ‡āŽŽā¯āŽŽāŽŋāŽšā¯āŽšā¯ˆ āŽĒāŽ°āŽžāŽŽāŽ°āŽŋāŽĒā¯āŽĒ❁ āŽŽā¯āŽąā¯ˆāŽ¯āŽŋāŽ˛ā¯ āŽĩā¯ˆāŽ•ā¯āŽ•āŽĩā¯āŽŽā¯.", "maintenance_start": "āŽĒāŽ°āŽžāŽŽāŽ°āŽŋāŽĒā¯āŽĒ❁ āŽĒāŽ¯āŽŠā¯āŽŽā¯āŽąā¯ˆāŽ¯ā¯ˆāŽ¤ā¯ āŽ¤ā¯ŠāŽŸāŽ™ā¯āŽ•ā¯", @@ -1480,7 +1482,6 @@ "not_available": "āŽ‡āŽ¤āŽąā¯āŽ•āŽŋāŽ˛ā¯āŽ˛ā¯ˆ", "not_in_any_album": "āŽŽāŽ¨ā¯āŽ¤ āŽ†āŽ˛ā¯āŽĒāŽ¤ā¯āŽ¤āŽŋāŽ˛ā¯āŽŽā¯ āŽ‡āŽ˛ā¯āŽ˛ā¯ˆ", "not_selected": "āŽ¤ā¯‡āŽ°ā¯āŽ¨ā¯āŽ¤ā¯†āŽŸā¯āŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸāŽĩāŽŋāŽ˛ā¯āŽ˛ā¯ˆ", - "note_apply_storage_label_to_previously_uploaded assets": "āŽ•ā¯āŽąāŽŋāŽĒā¯āŽĒ❁: āŽŽā¯āŽŠā¯āŽŠāŽ°ā¯ āŽĒāŽ¤āŽŋāŽĩā¯‡āŽąā¯āŽąāŽĒā¯āŽĒāŽŸā¯āŽŸ āŽšā¯ŠāŽ¤ā¯āŽ¤ā¯āŽ•ā¯āŽ•āŽŗā¯āŽ•ā¯āŽ•ā¯ āŽšā¯‡āŽŽāŽŋāŽĒā¯āŽĒāŽ• āŽ˛ā¯‡āŽĒāŽŋāŽŗā¯ˆ āŽĒāŽ¯āŽŠā¯āŽĒāŽŸā¯āŽ¤ā¯āŽ¤, āŽ‡āŽ¯āŽ•ā¯āŽ•āŽĩā¯āŽŽā¯", "notes": "āŽ•ā¯āŽąāŽŋāŽĒā¯āŽĒā¯āŽ•āŽŗā¯", "nothing_here_yet": "āŽ‡āŽŠā¯āŽŠā¯āŽŽā¯ āŽ‡āŽ™ā¯āŽ•ā¯‡ āŽŽāŽ¤ā¯āŽĩā¯āŽŽā¯ āŽ‡āŽ˛ā¯āŽ˛ā¯ˆ", "notification_permission_dialog_content": "āŽ…āŽąāŽŋāŽĩāŽŋāŽĒā¯āŽĒā¯āŽ•āŽŗā¯ˆ āŽ‡āŽ¯āŽ•ā¯āŽ•, āŽ…āŽŽā¯ˆāŽĒā¯āŽĒā¯āŽ•āŽŗā¯āŽ•ā¯āŽ•ā¯āŽšā¯ āŽšā¯†āŽŠā¯āŽąā¯ āŽ‡āŽšā¯ˆāŽĩ❁ āŽŽāŽŠā¯āŽĒāŽ¤ā¯ˆāŽ¤ā¯ āŽ¤ā¯‡āŽ°ā¯āŽ¨ā¯āŽ¤ā¯†āŽŸā¯āŽ•ā¯āŽ•āŽĩā¯āŽŽā¯.", @@ -1676,7 +1677,7 @@ "reassigned_assets_to_new_person": "āŽĒā¯āŽ¤āŽŋāŽ¯ āŽ¨āŽĒāŽ°ā¯āŽ•ā¯āŽ•ā¯ {count, plural, one {# āŽšā¯ŠāŽ¤ā¯āŽ¤ā¯} other {# āŽšā¯ŠāŽ¤ā¯āŽ¤ā¯āŽ•āŽŗā¯}} āŽŽā¯€āŽŖā¯āŽŸā¯āŽŽā¯ āŽ’āŽ¤ā¯āŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŸāŽ¤ā¯", "reassing_hint": "āŽ¤ā¯‡āŽ°ā¯āŽ¨ā¯āŽ¤ā¯†āŽŸā¯āŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŸ āŽšā¯ŠāŽ¤ā¯āŽ¤ā¯āŽ•ā¯āŽ•āŽŗā¯ˆ āŽāŽąā¯āŽ•āŽŠāŽĩ❇ āŽ‡āŽ°ā¯āŽ•ā¯āŽ•ā¯āŽŽā¯ āŽ¨āŽĒāŽ°ā¯āŽ•ā¯āŽ•ā¯ āŽ’āŽ¤ā¯āŽ•ā¯āŽ•ā¯āŽ™ā¯āŽ•āŽŗā¯", "recent": "āŽ…āŽŖā¯āŽŽā¯ˆāŽ•ā¯ āŽ•āŽžāŽ˛", - "recent-albums": "āŽ…āŽŖā¯āŽŽā¯ˆāŽ•ā¯ āŽ•āŽžāŽ˛ āŽ†āŽ˛ā¯āŽĒāŽ™ā¯āŽ•āŽŗā¯", + "recent_albums": "āŽ…āŽŖā¯āŽŽā¯ˆāŽ•ā¯ āŽ•āŽžāŽ˛ āŽ†āŽ˛ā¯āŽĒāŽ™ā¯āŽ•āŽŗā¯", "recent_searches": "āŽ…āŽŖā¯āŽŽā¯ˆāŽ•ā¯ āŽ•āŽžāŽ˛ āŽ¤ā¯‡āŽŸāŽ˛ā¯āŽ•āŽŗā¯", "recently_added": "āŽ…āŽŖā¯āŽŽā¯ˆāŽ•ā¯ āŽ•āŽžāŽ˛āŽ¤ā¯āŽ¤āŽŋāŽ˛ā¯ āŽšā¯‡āŽ°ā¯āŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŸāŽ¤ā¯", "recently_added_page_title": "āŽ…āŽŖā¯āŽŽā¯ˆāŽ•ā¯ āŽ•āŽžāŽ˛āŽ¤ā¯āŽ¤āŽŋāŽ˛ā¯ āŽšā¯‡āŽ°ā¯āŽ•ā¯āŽ•āŽĒā¯āŽĒāŽŸā¯āŽŸāŽ¤ā¯", diff --git a/i18n/te.json b/i18n/te.json index d9d24bb3c6..275b2deb42 100644 --- a/i18n/te.json +++ b/i18n/te.json @@ -895,7 +895,6 @@ "no_results_description": "ā°Ēā°°āąā°¯ā°žā°¯ā°Ēā°Ļā°‚ ā°˛āą‡ā°Ļā°ž ā°Žā°°ā°ŋā°‚ā°¤ ā°¸ā°žā°§ā°žā°°ā°Ŗ ā°•āą€ā°ĩā°°āąā°Ąāąâ€Œā°¨ā°ŋ ā°Ēāąā°°ā°¯ā°¤āąā°¨ā°ŋā°‚ā°šā°‚ā°Ąā°ŋ", "no_shared_albums_message": "ā°Žāą€ ā°¨āą†ā°Ÿāąâ€Œā°ĩā°°āąā°•āąâ€Œā°˛āą‹ā°¨ā°ŋ ā°ĩāąā°¯ā°•āąā°¤āąā°˛ā°¤āą‹ ā°Ģāą‹ā°Ÿāą‹ā°˛āą ā°Žā°°ā°ŋā°¯āą ā°ĩāą€ā°Ąā°ŋā°¯āą‹ā°˛ā°¨āą ā°­ā°žā°—ā°¸āąā°ĩā°žā°Žāąā°¯ā°‚ ā°šāą‡ā°¯ā°Ąā°žā°¨ā°ŋā°•ā°ŋ ā°†ā°˛āąā°Ŧā°Žāąâ€Œā°¨āą ā°¸āąƒā°ˇāąā°Ÿā°ŋā°‚ā°šā°‚ā°Ąā°ŋ", "not_in_any_album": "ā° ā°†ā°˛āąā°Ŧā°Žāąâ€Œā°˛āą‹ā°¨āą‚ ā°˛āą‡ā°Ļāą", - "note_apply_storage_label_to_previously_uploaded assets": "ā°—ā°Žā°¨ā°ŋā°•: ā°—ā°¤ā°‚ā°˛āą‹ ā°…ā°Ēāąâ€Œā°˛āą‹ā°Ąāą ā°šāą‡ā°¸ā°ŋā°¨ ā°†ā°¸āąā°¤āąā°˛ā°•āą ā°¨ā°ŋā°˛āąā°ĩ ā°˛āą‡ā°Ŧāąā°˛āąâ€Œā°¨āą ā°ĩā°°āąā°¤ā°ŋā°‚ā°Ēā°œāą‡ā°¯ā°Ąā°žā°¨ā°ŋā°•ā°ŋ,", "notes": "ā°—ā°Žā°¨ā°ŋā°•ā°˛āą", "notification_toggle_setting_description": "ā°‡ā°Žāą†ā°¯ā°ŋā°˛āą ā°¨āą‹ā°Ÿā°ŋā°Ģā°ŋā°•āą‡ā°ˇā°¨āąâ€Œā°˛ā°¨āą ā°Ēāąā°°ā°žā°°ā°‚ā°­ā°ŋā°‚ā°šā°‚ā°Ąā°ŋ", "notifications": "ā°¨āą‹ā°Ÿā°ŋā°Ģā°ŋā°•āą‡ā°ˇā°¨āąâ€Œā°˛āą", @@ -1021,7 +1020,7 @@ "reassign": "ā°¤ā°ŋā°°ā°ŋā°—ā°ŋ ā°•āą‡ā°Ÿā°žā°¯ā°ŋā°‚ā°šāą", "reassing_hint": "ā°Žā°‚ā°šāąā°•āąā°¨āąā°¨ ā°†ā°¸āąā°¤āąā°˛ā°¨āą ā°‡ā°Ēāąā°ĒⰟā°ŋā°•āą‡ ā°‰ā°¨āąā°¨ ā°ĩāąā°¯ā°•āąā°¤ā°ŋā°•ā°ŋ ā°•āą‡ā°Ÿā°žā°¯ā°ŋā°‚ā°šā°‚ā°Ąā°ŋ", "recent": "ā°‡ā°Ÿāą€ā°ĩā°˛ā°ŋ", - "recent-albums": "ā°‡ā°Ÿāą€ā°ĩā°˛ā°ŋ ā°†ā°˛āąā°Ŧā°Žāąâ€Œā°˛āą", + "recent_albums": "ā°‡ā°Ÿāą€ā°ĩā°˛ā°ŋ ā°†ā°˛āąā°Ŧā°Žāąâ€Œā°˛āą", "recent_searches": "ā°‡ā°Ÿāą€ā°ĩā°˛ā°ŋ ā°ļāą‹ā°§ā°¨ā°˛āą", "refresh": "ā°°ā°ŋā°Ģāąā°°āą†ā°ˇāą ā°šāą‡ā°¯ā°ŋ", "refresh_encoded_videos": "ā°Žā°¨āąâ€Œā°•āą‹ā°Ąāą ā°šāą‡ā°¸ā°ŋā°¨ ā°ĩāą€ā°Ąā°ŋā°¯āą‹ā°˛ā°¨āą ā°°ā°ŋā°Ģāąā°°āą†ā°ˇāą ā°šāą‡ā°¯ā°‚ā°Ąā°ŋ", diff --git a/i18n/th.json b/i18n/th.json index 413ee499a4..ee53ff2c9f 100644 --- a/i18n/th.json +++ b/i18n/th.json @@ -6,8 +6,8 @@ "action": "ā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗ", "action_common_update": "ā¸­ā¸ąā¸›āš€ā¸”ā¸•", "actions": "ā¸ā¸˛ā¸Ŗā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗ", - "active": "āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ā¸­ā¸ĸā¸šāšˆ", - "active_count": "āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ā¸­ā¸ĸā¸šāšˆ: {count}", + "active": "⏁⏺ā¸Ĩā¸ąā¸‡ā¸—ā¸ŗā¸‡ā¸˛ā¸™", + "active_count": "⏁⏺ā¸Ĩā¸ąā¸‡ā¸—ā¸ŗā¸‡ā¸˛ā¸™: {count}", "activity": "ā¸ā¸´ā¸ˆā¸ā¸Ŗā¸Ŗā¸Ą", "activity_changed": "ā¸ā¸´ā¸ˆā¸ā¸Ŗā¸Ŗā¸Ą{enabled, select, true {āš€ā¸›ā¸´ā¸”} other {⏛⏴⏔}}⏭ā¸ĸā¸šāšˆ", "add": "āš€ā¸žā¸´āšˆā¸Ą", @@ -16,6 +16,7 @@ "add_a_name": "āš€ā¸žā¸´āšˆā¸Ąā¸Šā¸ˇāšˆā¸­", "add_a_title": "āš€ā¸žā¸´āšˆā¸Ąā¸Ģā¸ąā¸§ā¸‚āš‰ā¸­", "add_action": "āš€ā¸žā¸´āšˆā¸Ąā¸ā¸˛ā¸Ŗā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗ", + "add_assets": "āš€ā¸žā¸´āšˆā¸Ąā¸Ēā¸ˇāšˆā¸­", "add_birthday": "āš€ā¸žā¸´āšˆā¸Ąā¸§ā¸ąā¸™āš€ā¸ā¸´ā¸”", "add_endpoint": "āš€ā¸žā¸´āšˆā¸Ąā¸›ā¸Ĩ⏞ā¸ĸ⏗⏞⏇", "add_exclusion_pattern": "āš€ā¸žā¸´āšˆā¸Ąā¸‚āš‰ā¸­ā¸ĸā¸āš€ā¸§āš‰ā¸™", @@ -26,7 +27,7 @@ "add_path": "āš€ā¸žā¸´āšˆā¸Ąā¸žā¸˛ā¸—ā¸—ā¸ĩāšˆā¸•ā¸ąāš‰ā¸‡", "add_photos": "āš€ā¸žā¸´āšˆā¸Ąā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž", "add_tag": "āš€ā¸žā¸´āšˆā¸Ąāšā¸—āš‡ā¸", - "add_to": "āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›ā¸ĸā¸ąā¸‡ â€Ļ", + "add_to": "āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›ā¸ĸā¸ąā¸‡â€Ļ", "add_to_album": "āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą", "add_to_album_bottom_sheet_added": "āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›ā¸ĸā¸ąā¸‡ {album} āšā¸Ĩāš‰ā¸§", "add_to_album_bottom_sheet_already_exists": "⏭ā¸ĸā¸šāšˆāšƒā¸™ {album} ⏭ā¸ĸā¸šāšˆāšā¸Ĩāš‰ā¸§", @@ -53,7 +54,7 @@ "backup_database_enable_description": "āš€ā¸›ā¸´ā¸”āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸ā¸˛ā¸™ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ", "backup_keep_last_amount": "ā¸ˆā¸ŗā¸™ā¸§ā¸™ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸āšˆā¸­ā¸™ā¸Ģā¸™āš‰ā¸˛ā¸—ā¸ĩāšˆā¸•āš‰ā¸­ā¸‡āš€ā¸āš‡ā¸šāš„ā¸§āš‰", "backup_onboarding_1_description": "ā¸Ēā¸ŗāš€ā¸™ā¸˛ā¸™ā¸­ā¸ā¸Ē⏖⏞⏙⏗ā¸ĩāšˆā¸šā¸™ā¸„ā¸Ĩā¸˛ā¸§ā¸”āšŒā¸Ģ⏪⏎⏭⏗ā¸ĩāšˆā¸•ā¸ąāš‰ā¸‡ā¸­ā¸ˇāšˆā¸™", - "backup_onboarding_2_description": "ā¸Ēā¸ŗāš€ā¸™ā¸˛ā¸—ā¸ĩāšˆā¸­ā¸ĸā¸šāšˆā¸šā¸™āš€ā¸„ā¸Ŗā¸ˇāšˆā¸­ā¸‡ā¸•āšˆā¸˛ā¸‡ā¸ā¸ąā¸™ ⏋ā¸ļāšˆā¸‡ā¸Ŗā¸§ā¸Ąā¸–ā¸ļā¸‡āš„ā¸Ÿā¸ĨāšŒā¸Ģā¸Ĩā¸ąā¸āšā¸Ĩā¸°āš„ā¸Ÿā¸ĨāšŒā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸šā¸™āš€ā¸„ā¸Ŗā¸ˇāšˆā¸­ā¸‡", + "backup_onboarding_2_description": "ā¸Ēā¸ŗāš€ā¸™ā¸˛ā¸—ā¸ĩāšˆā¸­ā¸ĸā¸šāšˆā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸—ā¸ĩāšˆā¸•āšˆā¸˛ā¸‡ā¸ā¸ąā¸™ ⏋ā¸ļāšˆā¸‡ā¸Ŗā¸§ā¸Ąā¸–ā¸ļā¸‡āš„ā¸Ÿā¸ĨāšŒā¸Ģā¸Ĩā¸ąā¸āšā¸Ĩā¸°āš„ā¸Ÿā¸ĨāšŒā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸šā¸™āš€ā¸„ā¸Ŗā¸ˇāšˆā¸­ā¸‡", "backup_onboarding_3_description": "ā¸ˆā¸ŗā¸™ā¸§ā¸™ā¸Šā¸¸ā¸”ā¸‚ā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸” ā¸Ŗā¸§ā¸Ąā¸–ā¸ļā¸‡āš„ā¸Ÿā¸ĨāšŒāš€ā¸”ā¸´ā¸Ą ⏋ā¸ļāšˆā¸‡ā¸Ŗā¸§ā¸Ąā¸–ā¸ļ⏇ 1 ā¸Šā¸¸ā¸”ā¸—ā¸ĩāšˆā¸•ā¸ąāš‰ā¸‡ā¸­ā¸ĸā¸šāšˆā¸„ā¸™ā¸Ĩā¸°ā¸–ā¸´āšˆā¸™ āšā¸Ĩ⏰ā¸Ēā¸ŗāš€ā¸™ā¸˛ā¸šā¸™āš€ā¸„ā¸Ŗā¸ˇāšˆā¸­ā¸‡ 2 ā¸Šā¸¸ā¸”", "backup_onboarding_description": "āšā¸™ā¸°ā¸™ā¸ŗāšƒā¸Ģāš‰āšƒā¸Šāš‰ ⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšā¸šā¸š 3-2-1āš€ā¸žā¸ˇāšˆā¸­ā¸›ā¸ā¸›āš‰ā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ ā¸„ā¸§ā¸Ŗāš€ā¸āš‡ā¸šā¸Ēā¸ŗāš€ā¸™ā¸˛ā¸‚ā¸­ā¸‡ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž/⏧⏴⏔ā¸ĩāš‚ā¸­ā¸—ā¸ĩāšˆā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”āšā¸Ĩā¸°ā¸ā¸˛ā¸™ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ⏂⏭⏇ Immich āš€ā¸žā¸ˇāšˆā¸­ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāš„ā¸”āš‰ā¸­ā¸ĸāšˆā¸˛ā¸‡ā¸—ā¸ąāšˆā¸§ā¸–ā¸ļ⏇", "backup_onboarding_footer": "ā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸šā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāš€ā¸žā¸´āšˆā¸Ąāš€ā¸•ā¸´ā¸Ąā¸—ā¸ĩāšˆāš€ā¸ā¸ĩāšˆā¸ĸā¸§ā¸ā¸ąā¸šā¸ā¸˛ā¸Ŗā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ⏂⏭⏇ Immich āš‚ā¸›ā¸Ŗā¸”ā¸”ā¸šā¸—ā¸ĩāšˆ documentation", @@ -64,7 +65,7 @@ "cleared_jobs": "āš€ā¸„ā¸Ĩā¸ĩā¸ĸā¸ŖāšŒā¸‡ā¸˛ā¸™ā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸š: {job}", "config_set_by_file": "ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸„ā¸­ā¸™ā¸Ÿā¸´ā¸ā¸ā¸ŗā¸Ĩā¸ąā¸‡ā¸–ā¸šā¸ā¸ā¸ŗā¸Ģā¸™ā¸”āš‚ā¸”ā¸ĸāš„ā¸Ÿā¸ĨāšŒā¸„ā¸­ā¸™ā¸Ÿā¸´ā¸", "confirm_delete_library": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸§āšˆā¸˛ā¸­ā¸ĸ⏞⏁ā¸Ĩā¸šā¸„ā¸Ĩā¸ąā¸‡ā¸ ā¸˛ā¸ž {library} ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ?", - "confirm_delete_library_assets": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸§āšˆā¸˛ā¸­ā¸ĸ⏞⏁ā¸Ĩā¸šā¸„ā¸Ĩā¸ąā¸‡ā¸ ā¸˛ā¸žā¸™ā¸ĩāš‰ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ? ā¸Ēā¸ĩāšˆā¸­ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸” {count, plural, one {# ā¸Ēā¸ˇāšˆā¸­} other {all # ā¸Ēā¸ˇāšˆā¸­}} ā¸Ēā¸ĩāšˆā¸­āšƒā¸™ā¸„ā¸Ĩā¸ąā¸‡ā¸ˆā¸°ā¸–ā¸šā¸ā¸Ĩ⏚⏭⏭⏁⏈⏞⏁ Immich āš‚ā¸”ā¸ĸ⏖⏞⏧⏪ āš„ā¸Ÿā¸ĨāšŒā¸ˆā¸°ā¸ĸā¸ąā¸‡ā¸„ā¸‡ā¸­ā¸ĸā¸šāšˆā¸šā¸™ā¸”ā¸´ā¸Ēā¸āšŒ", + "confirm_delete_library_assets": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸§āšˆā¸˛ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸Ĩā¸šā¸„ā¸Ĩā¸ąā¸‡ā¸ ā¸˛ā¸žā¸™ā¸ĩāš‰ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ? ā¸Ēā¸ĩāšˆā¸­ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸” {count, plural, one {# ā¸Ēā¸ˇāšˆā¸­} other {all # ā¸Ēā¸ˇāšˆā¸­}} ā¸Ēā¸ĩāšˆā¸­āšƒā¸™ā¸„ā¸Ĩā¸ąā¸‡ā¸ˆā¸°ā¸–ā¸šā¸ā¸Ĩ⏚⏭⏭⏁⏈⏞⏁ Immich āš‚ā¸”ā¸ĸ⏖⏞⏧⏪ āš„ā¸Ÿā¸ĨāšŒā¸ˆā¸°ā¸ĸā¸ąā¸‡ā¸„ā¸‡ā¸­ā¸ĸā¸šāšˆā¸šā¸™ā¸”ā¸´ā¸Ēā¸āšŒ", "confirm_email_below": "āš‚ā¸›ā¸Ŗā¸”ā¸ĸ⏎⏙ā¸ĸā¸ąā¸™ āš‚ā¸”ā¸ĸā¸ā¸˛ā¸Ŗā¸žā¸´ā¸Ąā¸žāšŒ \"{email}\" ā¸‚āš‰ā¸˛ā¸‡ā¸Ĩāšˆā¸˛ā¸‡", "confirm_reprocess_all_faces": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸§āšˆā¸˛ā¸„ā¸¸ā¸“ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸›ā¸Ŗā¸°ā¸Ąā¸§ā¸Ĩ⏜ā¸Ĩāšƒā¸šā¸Ģā¸™āš‰ā¸˛ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”āšƒā¸Ģā¸Ąāšˆ? ā¸Šā¸ˇāšˆā¸­ā¸„ā¸™ā¸ˆā¸°ā¸–ā¸šā¸ā¸Ĩā¸šāš„ā¸›ā¸”āš‰ā¸§ā¸ĸ", "confirm_user_password_reset": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸§āšˆā¸˛ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸Ŗā¸ĩāš€ā¸‹āš‡ā¸•ā¸Ŗā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™ā¸‚ā¸­ā¸‡ {user} ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ?", @@ -131,6 +132,10 @@ "logging_level_description": "āš€ā¸Ąā¸ˇāšˆā¸­āš€ā¸›ā¸´ā¸”āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ āšƒā¸Šāš‰ā¸Ŗā¸°ā¸”ā¸ąā¸šā¸ā¸˛ā¸Ŗā¸šā¸ąā¸™ā¸—ā¸ļā¸ā¸­ā¸°āš„ā¸Ŗ", "logging_settings": "ā¸ā¸˛ā¸Ŗā¸šā¸ąā¸™ā¸—ā¸ļ⏁", "machine_learning_availability_checks_description": "ā¸•ā¸Ŗā¸§ā¸ˆā¸ˆā¸ąā¸šāšā¸Ĩā¸°āš€ā¸Ĩā¸ˇā¸­ā¸āšƒā¸Šāš‰āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ machine learning āš‚ā¸”ā¸ĸā¸­ā¸ąā¸•āš‚ā¸™ā¸Ąā¸ąā¸•ā¸´", + "machine_learning_availability_checks_interval": "⏪⏰ā¸ĸā¸°āš€ā¸§ā¸Ĩā¸˛ā¸•ā¸Ŗā¸§ā¸ˆā¸Ē⏭⏚", + "machine_learning_availability_checks_interval_description": "⏪⏰ā¸ĸā¸°āš€ā¸§ā¸Ĩā¸˛āš€ā¸›āš‡ā¸™ā¸Ąā¸´ā¸Ĩā¸Ĩ⏴⏧⏴⏙⏞⏗ā¸ĩ⏪⏰ā¸Ģā¸§āšˆā¸˛ā¸‡ā¸ā¸˛ā¸Ŗā¸•ā¸Ŗā¸§ā¸ˆā¸Ēā¸­ā¸šā¸„ā¸§ā¸˛ā¸Ąā¸žā¸Ŗāš‰ā¸­ā¸Ąāšā¸•āšˆā¸Ĩā¸°ā¸„ā¸Ŗā¸ąāš‰ā¸‡", + "machine_learning_availability_checks_timeout": "⏄⏺⏂⏭ā¸Ģā¸Ąā¸”āš€ā¸§ā¸Ĩ⏞", + "machine_learning_availability_checks_timeout_description": "ā¸ˆā¸ŗā¸™ā¸§ā¸™ā¸Ąā¸´ā¸Ĩā¸Ĩ⏴⏧⏴⏙⏞⏗ā¸ĩ⏗ā¸ĩāšˆā¸ˆā¸°ā¸™ā¸ąā¸šā¸§āšˆā¸˛ā¸Ģā¸Ąā¸”āš€ā¸§ā¸Ĩ⏞ā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸šā¸ā¸˛ā¸Ŗā¸•ā¸Ŗā¸§ā¸ˆā¸Ēā¸­ā¸šā¸„ā¸§ā¸˛ā¸Ąā¸žā¸Ŗāš‰ā¸­ā¸Ą", "machine_learning_clip_model": "āš‚ā¸Ąāš€ā¸”ā¸Ĩ Clip", "machine_learning_clip_model_description": "ā¸Šā¸ˇāšˆā¸­ā¸‚ā¸­ā¸‡āš‚ā¸Ąāš€ā¸”ā¸Ĩ CLIP ⏗ā¸ĩāšˆā¸Ŗā¸°ā¸šā¸¸ā¸•ā¸Ŗā¸‡ā¸™ā¸ĩāš‰ āš‚ā¸›ā¸Ŗā¸”ā¸—ā¸Ŗā¸˛ā¸šā¸§āšˆā¸˛ā¸ˆā¸ŗāš€ā¸›āš‡ā¸™ā¸•āš‰ā¸­ā¸‡ā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸‡ā¸˛ā¸™ 'ā¸„āš‰ā¸™ā¸Ģā¸˛ā¸­ā¸ąā¸ˆā¸‰ā¸Ŗā¸´ā¸ĸ⏰' āšƒā¸Ģā¸Ąāšˆā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸šā¸—ā¸¸ā¸ā¸Ŗā¸šā¸›āš€ā¸Ąā¸ˇāšˆā¸­āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸā¸™āš‚ā¸Ąāš€ā¸”ā¸Ĩ", "machine_learning_duplicate_detection": "ā¸•ā¸Ŗā¸§ā¸ˆā¸ˆā¸ąā¸šā¸ā¸˛ā¸Ŗā¸‹āš‰ā¸ŗā¸ā¸ąā¸™", @@ -169,6 +174,8 @@ "machine_learning_smart_search_enabled": "āš€ā¸›ā¸´ā¸”āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģā¸˛ā¸­ā¸ąā¸ˆā¸‰ā¸Ŗā¸´ā¸ĸ⏰", "machine_learning_smart_search_enabled_description": "ā¸Ģā¸˛ā¸ā¸›ā¸´ā¸”āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ ā¸ ā¸˛ā¸žā¸ˆā¸°āš„ā¸Ąāšˆā¸–ā¸šā¸āšƒā¸Šāš‰ā¸Ēāšā¸˛ā¸Ģā¸Ŗā¸ąā¸šā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģā¸˛ā¸­ā¸ąā¸ˆā¸‰ā¸Ŗā¸´ā¸ĸ⏰", "machine_learning_url_description": "URL ā¸‚ā¸­ā¸‡āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ machine learning ⏁⏪⏓ā¸ĩā¸Ąā¸ĩ URL ā¸Ąā¸˛ā¸ā¸ā¸§āšˆā¸˛ā¸Ģ⏙ā¸ļāšˆā¸‡ URL ā¸ˆā¸°ā¸—ā¸ŗā¸ā¸˛ā¸Ŗā¸—ā¸”ā¸Ĩ⏭⏇ā¸Ēāšˆā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāš€ā¸Ŗā¸ĩā¸ĸā¸‡āš„ā¸›ā¸—ā¸ĩā¸Ĩā¸°ā¸­ā¸ąā¸™ā¸•ā¸˛ā¸Ąā¸Ĩā¸ŗā¸”ā¸ąā¸šā¸ˆā¸™ā¸ā¸§āšˆā¸˛ā¸ˆā¸°ā¸žā¸š URL ⏗ā¸ĩāšˆā¸•ā¸­ā¸šā¸Ē⏙⏭⏇ āšā¸Ĩā¸°ā¸ˆā¸°āš€ā¸Ĩ⏴⏁ā¸Ēāšˆā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸Šā¸ąāšˆā¸§ā¸„ā¸Ŗā¸˛ā¸§āšƒā¸™ā¸Ēāšˆā¸§ā¸™ā¸‚ā¸­ā¸‡ URL ⏗ā¸ĩāšˆāš„ā¸Ąāšˆā¸•ā¸­ā¸šā¸Ē⏙⏭⏇", + "maintenance_delete_backup": "ā¸Ĩ⏚⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ", + "maintenance_delete_backup_description": "āš„ā¸Ÿā¸ĨāšŒā¸™ā¸ĩāš‰ā¸ˆā¸°ā¸–ā¸šā¸ā¸Ĩā¸šāšā¸Ĩā¸°āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸ĸāš‰ā¸­ā¸™ā¸ā¸Ĩā¸ąā¸šāš„ā¸”āš‰", "manage_concurrency": "ā¸ˆā¸ąā¸”ā¸ā¸˛ā¸Ŗā¸ā¸˛ā¸Ŗā¸—ā¸ŗā¸‡ā¸˛ā¸™ā¸žā¸Ŗāš‰ā¸­ā¸Ąā¸ā¸ąā¸™", "manage_log_settings": "ā¸ˆā¸ąā¸”ā¸ā¸˛ā¸Ŗā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸ˆā¸”ā¸šā¸ąā¸™ā¸—ā¸ļ⏁", "map_dark_style": "āšā¸šā¸šā¸Ąā¸ˇā¸”", @@ -193,9 +200,14 @@ "metadata_settings": "ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ Metadata", "metadata_settings_description": "ā¸ˆā¸ąā¸”ā¸ā¸˛ā¸Ŗā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ Metadata", "migration_job": "ā¸ā¸˛ā¸Ŗāš‚ā¸ĸ⏁ā¸ĸāš‰ā¸˛ā¸ĸ", - "migration_job_description": "ā¸ĸāš‰ā¸˛ā¸ĸā¸ ā¸˛ā¸žā¸•ā¸ąā¸§ā¸­ā¸ĸāšˆā¸˛ā¸‡ā¸Ēā¸ˇāšˆā¸­āšā¸Ĩā¸°āšƒā¸šā¸Ģā¸™āš‰ā¸˛āš„ā¸›ā¸ĸā¸ąā¸‡āš‚ā¸„ā¸Ŗā¸‡ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡āš‚ā¸Ÿā¸Ĩāš€ā¸”ā¸­ā¸ŖāšŒā¸Ĩāšˆā¸˛ā¸Ē⏏⏔", + "migration_job_description": "ā¸ĸāš‰ā¸˛ā¸ĸā¸ ā¸˛ā¸žā¸•ā¸ąā¸§ā¸­ā¸ĸāšˆā¸˛ā¸‡ā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸šā¸Ēā¸ˇāšˆā¸­āšā¸Ĩā¸°āšƒā¸šā¸Ģā¸™āš‰ā¸˛āš„ā¸›ā¸ĸā¸ąā¸‡āš‚ā¸„ā¸Ŗā¸‡ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡āš‚ā¸Ÿā¸Ĩāš€ā¸”ā¸­ā¸ŖāšŒā¸Ĩāšˆā¸˛ā¸Ē⏏⏔", "nightly_tasks_cluster_new_faces_setting": "⏄ā¸Ĩā¸ąā¸Ēāš€ā¸•ā¸­ā¸ŖāšŒāšƒā¸šā¸Ģā¸™āš‰ā¸˛āšƒā¸Ģā¸Ąāšˆ", "nightly_tasks_generate_memories_setting": "ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸„ā¸§ā¸˛ā¸Ąā¸—ā¸Ŗā¸‡ā¸ˆā¸ŗ", + "nightly_tasks_generate_memories_setting_description": "ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸„ā¸§ā¸˛ā¸Ąā¸—ā¸Ŗā¸‡ā¸ˆā¸ŗāšƒā¸Ģā¸Ąāšˆā¸ˆā¸˛ā¸ā¸Ēā¸ˇāšˆā¸­", + "nightly_tasks_missing_thumbnails_setting": "ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸ ā¸˛ā¸žā¸‚ā¸™ā¸˛ā¸”ā¸ĸāšˆā¸­ā¸—ā¸ĩāšˆā¸‚ā¸˛ā¸”ā¸Ģ⏞ā¸ĸāš„ā¸›", + "nightly_tasks_missing_thumbnails_setting_description": "āš€ā¸žā¸´āšˆā¸Ąā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆāš„ā¸Ąāšˆā¸Ąā¸ĩā¸ ā¸˛ā¸žā¸‚ā¸™ā¸˛ā¸”ā¸ĸāšˆā¸­āš„ā¸›ā¸ĸā¸ąā¸‡ā¸„ā¸´ā¸§āš€ā¸žā¸ˇāšˆā¸­ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸ ā¸˛ā¸žā¸‚ā¸™ā¸˛ā¸”ā¸ĸāšˆā¸­", + "nightly_tasks_start_time_setting": "āš€ā¸§ā¸Ĩā¸˛āš€ā¸Ŗā¸´āšˆā¸Ąā¸•āš‰ā¸™", + "nightly_tasks_start_time_setting_description": "āš€ā¸§ā¸Ĩ⏞⏗ā¸ĩāšˆāš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒā¸ˆā¸°āš€ā¸Ŗā¸´āšˆā¸Ąā¸‡ā¸˛ā¸™ā¸›ā¸Ŗā¸°ā¸ˆā¸ŗā¸„ā¸ˇā¸™", "no_paths_added": "āš„ā¸Ąāšˆāš„ā¸”āš‰āš€ā¸žā¸´āšˆā¸Ąā¸žā¸˛ā¸˜", "no_pattern_added": "āš„ā¸Ąāšˆāš„ā¸”āš‰āš€ā¸žā¸´āšˆā¸Ąā¸Ŗā¸šā¸›āšā¸šā¸š", "note_apply_storage_label_previous_assets": "ā¸Ģā¸˛ā¸ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗāšƒā¸Šāš‰ Storage Label ā¸ā¸ąā¸šāš„ā¸Ÿā¸ĨāšŒā¸—ā¸ĩāšˆā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”ā¸āšˆā¸­ā¸™ā¸Ģā¸™āš‰ā¸˛ā¸™ā¸ĩāš‰ āšƒā¸Ģāš‰ā¸Ŗā¸ąā¸™ā¸„ā¸ŗā¸Ēā¸ąāšˆā¸‡ā¸™ā¸ĩāš‰", @@ -327,7 +339,7 @@ "transcoding_max_b_frames": "B-frames ā¸Ēā¸šā¸‡ā¸Ē⏏⏔", "transcoding_max_b_frames_description": "ā¸„āšˆā¸˛ā¸—ā¸ĩāšˆā¸Ēā¸šā¸‡ā¸‚ā¸ļāš‰ā¸™ā¸ˆā¸°ā¸Šāšˆā¸§ā¸ĸāš€ā¸žā¸´āšˆā¸Ąā¸›ā¸Ŗā¸°ā¸Ēā¸´ā¸—ā¸˜ā¸´ā¸ ā¸˛ā¸žāšƒā¸™ā¸ā¸˛ā¸Ŗā¸šā¸ĩā¸šā¸­ā¸ąā¸” āšā¸•āšˆā¸ˆā¸°ā¸—ā¸ŗāšƒā¸Ģāš‰ā¸ā¸˛ā¸Ŗāš€ā¸‚āš‰ā¸˛ā¸Ŗā¸Ģā¸ąā¸Ēā¸Šāš‰ā¸˛ā¸Ĩ⏇ ā¸­ā¸˛ā¸ˆāš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ā¸Ŗāšˆā¸§ā¸Ąā¸ā¸ąā¸šā¸ā¸˛ā¸Ŗāš€ā¸Ŗāšˆā¸‡ā¸„ā¸§ā¸˛ā¸Ąāš€ā¸Ŗāš‡ā¸§ā¸Žā¸˛ā¸ŖāšŒā¸”āšā¸§ā¸ŖāšŒā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒāš€ā¸āšˆā¸˛āš„ā¸”āš‰ ā¸„āšˆā¸˛ā¸—ā¸ĩāšˆāš€ā¸›āš‡ā¸™ 0 ā¸ˆā¸°ā¸›ā¸´ā¸”ā¸ā¸˛ā¸Ŗāšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ B-frame āšƒā¸™ā¸‚ā¸“ā¸°ā¸—ā¸ĩāšˆā¸„āšˆā¸˛ -1 ā¸ˆā¸°ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸„āšˆā¸˛ā¸™ā¸ĩāš‰āš‚ā¸”ā¸ĸā¸­ā¸ąā¸•āš‚ā¸™ā¸Ąā¸ąā¸•ā¸´", "transcoding_max_bitrate": "bitrate ā¸Ēā¸šā¸‡ā¸Ē⏏⏔", - "transcoding_max_bitrate_description": "ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ bitrate ā¸Ēā¸šā¸‡ā¸Ēā¸¸ā¸”ā¸ˆā¸°ā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸„ā¸˛ā¸”āš€ā¸”ā¸˛ā¸‚ā¸™ā¸˛ā¸”āš„ā¸Ÿā¸ĨāšŒāš„ā¸”āš‰ā¸Ąā¸˛ā¸ā¸‚ā¸ļāš‰ā¸™āš‚ā¸”ā¸ĸāš„ā¸Ąāšˆā¸ā¸Ŗā¸°ā¸—ā¸šā¸„ā¸¸ā¸“ā¸ ā¸˛ā¸ž ā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸šā¸„ā¸§ā¸˛ā¸Ąā¸„ā¸Ąā¸Šā¸ąā¸” 720p ā¸„āšˆā¸˛ā¸—ā¸ąāšˆā¸§āš„ā¸›ā¸„ā¸ˇā¸­ 2600 kbit/s ā¸Ēāšā¸˛ā¸Ģā¸Ŗā¸ąā¸š VP9 ā¸Ģ⏪⏎⏭ HEVC, 4500 kbit/s ā¸Ēāšā¸˛ā¸Ģā¸Ŗā¸ąā¸š H.264 ā¸›ā¸´ā¸”ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛āš€ā¸Ąā¸ĩāšˆā¸­ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛āš€ā¸›āš‡ā¸™ 0", + "transcoding_max_bitrate_description": "ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ bitrate ā¸Ēā¸šā¸‡ā¸Ēā¸¸ā¸”ā¸ˆā¸°ā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸„ā¸˛ā¸”āš€ā¸”ā¸˛ā¸‚ā¸™ā¸˛ā¸”āš„ā¸Ÿā¸ĨāšŒāš„ā¸”āš‰ā¸‡āšˆā¸˛ā¸ĸ⏂ā¸ļāš‰ā¸™āš‚ā¸”ā¸ĸā¸ā¸Ŗā¸°ā¸—ā¸šā¸„ā¸¸ā¸“ā¸ ā¸˛ā¸žāš€ā¸Ĩāš‡ā¸ā¸™āš‰ā¸­ā¸ĸ ā¸„āšˆā¸˛ā¸—ā¸ąāšˆā¸§āš„ā¸›ā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸šā¸„ā¸§ā¸˛ā¸Ąā¸„ā¸Ąā¸Šā¸ąā¸” 720p ⏄⏎⏭ 2600 kbit/s ā¸Ēāšā¸˛ā¸Ģā¸Ŗā¸ąā¸š VP9 ā¸Ģ⏪⏎⏭ HEVC, ā¸Ģ⏪⏎⏭ 4500 kbit/s ā¸Ēāšā¸˛ā¸Ģā¸Ŗā¸ąā¸š H.264 ā¸›ā¸´ā¸”ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛āš€ā¸Ąā¸ĩāšˆā¸­ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛āš€ā¸›āš‡ā¸™ 0 ā¸Ģā¸˛ā¸āš„ā¸Ąāšˆā¸Ŗā¸°ā¸šā¸¸ā¸Ģā¸™āšˆā¸§ā¸ĸā¸Ŗā¸°ā¸šā¸šā¸ˆā¸°ā¸–ā¸ˇā¸­ā¸§āšˆā¸˛āšƒā¸Šāš‰ā¸Ģā¸™āšˆā¸§ā¸ĸ k (kbit/s) ā¸”ā¸ąā¸‡ā¸™ā¸ąāš‰ā¸™ā¸„āšˆā¸˛ 5000, 5000k āšā¸Ĩ⏰ 5M (Mbit/s) ā¸–ā¸ˇā¸­ā¸§āšˆā¸˛āš€ā¸—ā¸ĩā¸ĸā¸šāš€ā¸—āšˆā¸˛ā¸ā¸ąā¸™", "transcoding_max_keyframe_interval": "ā¸Šāšˆā¸§ā¸‡āš€ā¸§ā¸Ĩ⏞ā¸Ēā¸šā¸‡ā¸Ē⏏⏔⏪⏰ā¸Ģā¸§āšˆā¸˛ā¸‡ā¸ā¸Ŗā¸˛ā¸Ÿā¸ŸāšŒāš€ā¸„ā¸Ĩā¸ˇāšˆā¸­ā¸™āš„ā¸Ģ⏧", "transcoding_max_keyframe_interval_description": "ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸Ŗā¸°ā¸ĸ⏰ā¸Ģāšˆā¸˛ā¸‡ā¸Ēā¸šā¸‡ā¸Ē⏏⏔⏪⏰ā¸Ģā¸§āšˆā¸˛ā¸‡ā¸„ā¸ĩā¸ĸāšŒāš€ā¸Ÿā¸Ŗā¸Ą (keyframes) ā¸„āšˆā¸˛ā¸—ā¸ĩāšˆā¸•āšˆā¸ŗā¸Ĩā¸‡ā¸ˆā¸°ā¸—ā¸ŗāšƒā¸Ģāš‰ā¸›ā¸Ŗā¸°ā¸Ēā¸´ā¸—ā¸˜ā¸´ā¸ ā¸˛ā¸žā¸ā¸˛ā¸Ŗā¸šā¸ĩā¸šā¸­ā¸ąā¸”āšā¸ĸāšˆā¸Ĩ⏇ āšā¸•āšˆā¸ˆā¸°ā¸Šāšˆā¸§ā¸ĸā¸›ā¸Ŗā¸ąā¸šā¸›ā¸Ŗā¸¸ā¸‡āš€ā¸§ā¸Ĩā¸˛āšƒā¸™ā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģā¸˛ā¸ ā¸˛ā¸ž (seek times) āšā¸Ĩā¸°ā¸­ā¸˛ā¸ˆā¸Šāšˆā¸§ā¸ĸā¸›ā¸Ŗā¸ąā¸šā¸›ā¸Ŗā¸¸ā¸‡ā¸„ā¸¸ā¸“ā¸ ā¸˛ā¸žāšƒā¸™ā¸‰ā¸˛ā¸ā¸—ā¸ĩāšˆā¸Ąā¸ĩā¸ā¸˛ā¸Ŗāš€ā¸„ā¸Ĩā¸ˇāšˆā¸­ā¸™āš„ā¸Ģā¸§āš€ā¸Ŗāš‡ā¸§ ā¸„āšˆā¸˛ 0 ā¸ˆā¸°ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸™ā¸ĩāš‰āš‚ā¸”ā¸ĸā¸­ā¸ąā¸•āš‚ā¸™ā¸Ąā¸ąā¸•ā¸´", "transcoding_optimal_description": "⏧ā¸ĩā¸”ā¸´āš‚ā¸­ā¸Ąā¸ĩā¸„ā¸§ā¸˛ā¸Ąā¸„ā¸Ąā¸Šā¸ąā¸”ā¸Ēā¸šā¸‡ā¸ā¸§āšˆā¸˛āš€ā¸›āš‰ā¸˛ā¸Ģā¸Ąā¸˛ā¸ĸā¸Ģ⏪⏎⏭⏭ā¸ĸā¸šāšˆāšƒā¸™ā¸Ŗā¸šā¸›āšā¸šā¸šā¸—ā¸ĩāšˆā¸Ŗā¸ąā¸šāš„ā¸Ąāšˆāš„ā¸”āš‰", @@ -395,7 +407,7 @@ "advanced_settings_proxy_headers_title": "ā¸žāš‡ā¸­ā¸ā¸‹ā¸ĩāšˆ āš€ā¸Žā¸”āš€ā¸”ā¸­ā¸ŖāšŒ", "advanced_settings_self_signed_ssl_subtitle": "ā¸‚āš‰ā¸˛ā¸Ąā¸ā¸˛ā¸Ŗā¸•ā¸Ŗā¸§ā¸ˆā¸Ēā¸­ā¸šāšƒā¸šā¸Ŗā¸ąā¸šā¸Ŗā¸­ā¸‡ SSL ā¸ˆā¸ŗāš€ā¸›āš‡ā¸™ā¸Ē⏺ā¸Ģā¸Ŗā¸ąā¸šāšƒā¸šā¸Ŗā¸ąā¸šā¸Ŗā¸­ā¸‡āšā¸šā¸š self-signed", "advanced_settings_self_signed_ssl_title": "ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•āšƒā¸šā¸Ŗā¸ąā¸šā¸Ŗā¸­ā¸‡ SSL āšā¸šā¸š self-signed", - "advanced_settings_sync_remote_deletions_subtitle": "⏚ā¸Ģā¸Ŗā¸ˇā¸­ā¸ā¸šāš‰ā¸„ā¸ˇā¸™āš„ā¸Ÿā¸ĨāšŒā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰āš‚ā¸”ā¸ĸā¸­ā¸ąā¸•āš‚ā¸™ā¸Ąā¸ąā¸•ā¸´āš€ā¸Ąā¸ˇāšˆā¸­ā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗā¸”ā¸ąā¸‡ā¸ā¸Ĩāšˆā¸˛ā¸§ā¸œāšˆā¸˛ā¸™āš€ā¸§āš‡ā¸š", + "advanced_settings_sync_remote_deletions_subtitle": "ā¸Ĩ⏚ā¸Ģā¸Ŗā¸ˇā¸­ā¸ā¸šāš‰ā¸„ā¸ˇā¸™āš„ā¸Ÿā¸ĨāšŒā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰āš‚ā¸”ā¸ĸā¸­ā¸ąā¸•āš‚ā¸™ā¸Ąā¸ąā¸•ā¸´āš€ā¸Ąā¸ˇāšˆā¸­ā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗā¸”ā¸ąā¸‡ā¸ā¸Ĩāšˆā¸˛ā¸§ā¸œāšˆā¸˛ā¸™āš€ā¸§āš‡ā¸š", "advanced_settings_sync_remote_deletions_title": "ā¸‹ā¸´ā¸‡ā¸āšŒā¸ā¸˛ā¸Ŗā¸Ĩ⏚⏈⏞⏁⏪⏰ā¸ĸā¸°āš„ā¸ā¸Ĩ [⏄⏏⏓ā¸Ēā¸Ąā¸šā¸ąā¸•ā¸´ā¸—ā¸”ā¸Ĩ⏭⏇]", "advanced_settings_tile_subtitle": "ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸œā¸šāš‰āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™ā¸‚ā¸ąāš‰ā¸™ā¸Ēā¸šā¸‡", "advanced_settings_troubleshooting_subtitle": "āš€ā¸›ā¸´ā¸”ā¸Ÿā¸ĩāš€ā¸ˆā¸­ā¸ŖāšŒāš€ā¸žā¸´āšˆā¸Ąāš€ā¸•ā¸´ā¸Ąāš€ā¸žā¸ˇāšˆā¸­āšā¸āš‰āš„ā¸‚ā¸›ā¸ąā¸ā¸Ģ⏞", @@ -403,11 +415,13 @@ "age_months": "⏭⏞ā¸ĸ⏏ {months, plural, one {# āš€ā¸”ā¸ˇā¸­ā¸™} other {# āš€ā¸”ā¸ˇā¸­ā¸™}}", "age_year_months": "⏭⏞ā¸ĸ⏏ 1 ⏛ā¸ĩ {months, plural, one {# āš€ā¸”ā¸ˇā¸­ā¸™} other {# āš€ā¸”ā¸ˇā¸­ā¸™}}", "age_years": "{years, plural, other {⏭⏞ā¸ĸ⏏ #}}", + "album": "ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą", "album_added": "āš€ā¸žā¸´āšˆā¸Ąā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšā¸Ĩāš‰ā¸§", "album_added_notification_setting_description": "āšā¸ˆāš‰ā¸‡āš€ā¸•ā¸ˇā¸­ā¸™ā¸­ā¸ĩāš€ā¸Ąā¸Ĩāš€ā¸Ąā¸ˇāšˆā¸­ā¸„ā¸¸ā¸“ā¸–ā¸šā¸āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›āšƒā¸™ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸—ā¸ĩāšˆāšā¸Šā¸ŖāšŒā¸ā¸ąā¸™", "album_cover_updated": "ā¸­ā¸ąā¸žāš€ā¸”ā¸—ā¸Ģā¸™āš‰ā¸˛ā¸›ā¸ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšā¸Ĩāš‰ā¸§", "album_delete_confirmation": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸—ā¸ĩāšˆā¸ˆā¸°ā¸Ĩā¸šā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą {album} ⏙ā¸ĩāš‰ ?", "album_delete_confirmation_description": "ā¸Ģā¸˛ā¸āšā¸Šā¸ŖāšŒā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸™ā¸ĩāš‰ ā¸œā¸šāš‰āšƒā¸Šāš‰ā¸Ŗā¸˛ā¸ĸā¸­ā¸ˇāšˆā¸™ā¸ˆā¸°āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–āš€ā¸‚āš‰ā¸˛ā¸–ā¸ļā¸‡āš„ā¸”āš‰ā¸­ā¸ĩ⏁", + "album_deleted": "ā¸Ĩā¸šā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšā¸Ĩāš‰ā¸§", "album_info_card_backup_album_excluded": "ā¸–ā¸šā¸ā¸ĸā¸āš€ā¸§āš‰ā¸™", "album_info_card_backup_album_included": "ā¸Ŗā¸§ā¸Ą", "album_info_updated": "ā¸­ā¸ąā¸›āš€ā¸”ā¸—ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšā¸Ĩāš‰ā¸§", @@ -417,9 +431,13 @@ "album_options": "ā¸•ā¸ąā¸§āš€ā¸Ĩā¸ˇā¸­ā¸ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą", "album_remove_user": "ā¸Ĩā¸šā¸œā¸šāš‰āšƒā¸Šāš‰ ?", "album_remove_user_confirmation": "ā¸„ā¸¸ā¸“ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸—ā¸ĩāšˆā¸ˆā¸°ā¸Ĩā¸šā¸œā¸šāš‰āšƒā¸Šāš‰ {user} ?", + "album_search_not_found": "āš„ā¸Ąāšˆā¸žā¸šā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸—ā¸ĩāšˆā¸•ā¸Ŗā¸‡ā¸•ā¸˛ā¸Ąā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģ⏞⏂⏭⏇⏄⏏⏓", + "album_selected": "āš€ā¸Ĩā¸ˇā¸­ā¸ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšā¸Ĩāš‰ā¸§", "album_share_no_users": "ā¸”ā¸šāš€ā¸Ģā¸Ąā¸ˇā¸­ā¸™ā¸§āšˆā¸˛ā¸„ā¸¸ā¸“āš„ā¸”āš‰āšā¸Šā¸ŖāšŒā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸™ā¸ĩāš‰ā¸ā¸ąā¸šā¸œā¸šāš‰āšƒā¸Šāš‰ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”āšā¸Ĩāš‰ā¸§", + "album_summary": "ā¸Ēā¸Ŗā¸¸ā¸›ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą", "album_updated": "ā¸­ā¸ąā¸›āš€ā¸”ā¸—ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšā¸Ĩāš‰ā¸§", "album_updated_setting_description": "āšā¸ˆāš‰ā¸‡āš€ā¸•ā¸ˇā¸­ā¸™ā¸­ā¸ĩāš€ā¸Ąā¸Ĩāš€ā¸Ąā¸ˇāšˆā¸­ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸—ā¸ĩāšˆāšā¸Šā¸ŖāšŒā¸ā¸ąā¸™ā¸Ąā¸ĩā¸Ēā¸ˇāšˆā¸­āšƒā¸Ģā¸Ąāšˆ", + "album_upload_assets": "ā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩ⏔ā¸Ēā¸ˇāšˆā¸­ā¸ˆā¸˛ā¸ā¸„ā¸­ā¸Ąā¸žā¸´ā¸§āš€ā¸•ā¸­ā¸ŖāšŒāš€ā¸žā¸ˇāšˆā¸­āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą", "album_user_left": "⏭⏭⏁⏈⏞⏁ {album}", "album_user_removed": "ā¸Ĩā¸šā¸œā¸šāš‰āšƒā¸Šāš‰ {user} āšā¸Ĩāš‰ā¸§", "album_viewer_appbar_delete_confirm": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸§āšˆā¸˛ā¸­ā¸ĸ⏞⏁ā¸Ĩā¸šā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸™ā¸ĩāš‰ā¸ˆā¸˛ā¸ā¸šā¸ąā¸ā¸Šā¸ĩ⏄⏏⏓ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ", @@ -436,15 +454,20 @@ "albums_default_sort_order": "ā¸ā¸˛ā¸Ŗā¸ˆā¸ąā¸”āš€ā¸Ŗā¸ĩā¸ĸā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāš€ā¸Ŗā¸´āšˆā¸Ąā¸•āš‰ā¸™", "albums_default_sort_order_description": "ā¸ā¸˛ā¸Ŗā¸ˆā¸ąā¸”āš€ā¸Ŗā¸ĩā¸ĸā¸‡āšā¸­ā¸Ēāš€ā¸‹āš‡ā¸•āš€ā¸Ŗā¸´āšˆā¸Ąā¸•āš‰ā¸™āš€ā¸Ąā¸ˇāšˆā¸­ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšƒā¸Ģā¸Ąāšˆ", "albums_feature_description": "⏁ā¸Ĩā¸¸āšˆā¸Ąā¸‚ā¸­ā¸‡āšā¸­ā¸Ēāš€ā¸‹āš‡ā¸•ā¸—ā¸ĩāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸Ēāšˆā¸‡āšƒā¸Ģāš‰ā¸œā¸šāš‰āšƒā¸Šāš‰ā¸­ā¸ˇāšˆā¸™āš„ā¸”āš‰", + "albums_on_device_count": "ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒ ({count})", + "albums_selected": "{count, plural, one {āš€ā¸Ĩ⏎⏭⏁ # ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą} other {āš€ā¸Ĩ⏎⏭⏁ # ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą}}", "all": "ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", "all_albums": "ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", "all_people": "⏗⏏⏁⏄⏙", + "all_photos": "ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸žā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", "all_videos": "⏧⏴⏔ā¸ĩāš‚ā¸­ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", "allow_dark_mode": "ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•āš‚ā¸Ģā¸Ąā¸”ā¸Ąā¸ˇā¸”", "allow_edits": "ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•āšƒā¸Ģāš‰āšā¸āš‰āš„ā¸‚āš„ā¸”āš‰", "allow_public_user_to_download": "ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•āšƒā¸Ģāš‰ā¸œā¸šāš‰āšƒā¸Šāš‰ā¸Ēā¸˛ā¸˜ā¸˛ā¸Ŗā¸“ā¸°ā¸”ā¸˛ā¸§ā¸™āšŒāš‚ā¸Ģā¸Ĩā¸”āš„ā¸”āš‰", "allow_public_user_to_upload": "ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•āšƒā¸Ģāš‰ā¸œā¸šāš‰āšƒā¸Šāš‰ā¸Ēā¸˛ā¸˜ā¸˛ā¸Ŗā¸“ā¸°ā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”āš„ā¸”āš‰", + "allowed": "ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•", "alt_text_qr_code": "ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž QR code", + "always_keep": "āš€ā¸āš‡ā¸šāš€ā¸Ēā¸Ąā¸­", "always_keep_photos_hint": "\"āš€ā¸žā¸´āšˆā¸Ąā¸žā¸ˇāš‰ā¸™ā¸—ā¸ĩāšˆā¸§āšˆā¸˛ā¸‡\" ā¸ˆā¸°āš€ā¸āš‡ā¸šā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸žā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”ā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰", "always_keep_videos_hint": "\"āš€ā¸žā¸´āšˆā¸Ąā¸žā¸ˇāš‰ā¸™ā¸—ā¸ĩāšˆā¸§āšˆā¸˛ā¸‡\" ā¸ˆā¸°āš€ā¸āš‡ā¸šā¸§ā¸´ā¸”ā¸ĩāš‚ā¸­ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”ā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰", "anti_clockwise": "ā¸—ā¸§ā¸™āš€ā¸‚āš‡ā¸Ąā¸™ā¸˛ā¸Ŧ⏴⏁⏞", @@ -455,9 +478,13 @@ "app_bar_signout_dialog_content": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸§āšˆā¸˛ā¸­ā¸ĸ⏞⏁⏭⏭⏁⏈⏞⏁⏪⏰⏚⏚", "app_bar_signout_dialog_ok": "āšƒā¸Šāšˆ", "app_bar_signout_dialog_title": "⏭⏭⏁⏈⏞⏁⏪⏰⏚⏚", + "app_download_links": "ā¸Ĩā¸´ā¸‡ā¸āšŒā¸”ā¸˛ā¸§ā¸™āšŒāš‚ā¸Ģā¸Ĩā¸”āšā¸­ā¸›", "app_settings": "ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛āšā¸­ā¸›", + "app_stores": "ā¸Ŗāš‰ā¸˛ā¸™ā¸„āš‰ā¸˛āšā¸­ā¸›", + "app_update_available": "ā¸Ąā¸ĩā¸­ā¸ąā¸›āš€ā¸”ā¸•āšā¸­ā¸›", "appears_in": "⏭ā¸ĸā¸šāšˆāšƒā¸™", "archive": "āš€ā¸āš‡ā¸šā¸–ā¸˛ā¸§ā¸Ŗ", + "archive_action_prompt": "āš€ā¸žā¸´āšˆā¸Ą {count} ⏪⏞ā¸ĸā¸ā¸˛ā¸Ŗāš„ā¸›ā¸ĸā¸ąā¸‡āš€ā¸āš‡ā¸šā¸–ā¸˛ā¸§ā¸Ŗāšā¸Ĩāš‰ā¸§", "archive_or_unarchive_photo": "āš€ā¸āš‡ā¸š/āš„ā¸Ąāšˆāš€ā¸āš‡ā¸šā¸ ā¸˛ā¸žā¸–ā¸˛ā¸§ā¸Ŗ", "archive_page_no_archived_assets": "āš„ā¸Ąāšˆā¸žā¸šā¸—ā¸Ŗā¸ąā¸žā¸ĸā¸˛ā¸ā¸Ŗāšƒā¸™ā¸—ā¸ĩāšˆāš€ā¸āš‡ā¸šā¸–ā¸˛ā¸§ā¸Ŗ", "archive_page_title": "āš€ā¸āš‡ā¸šā¸–ā¸˛ā¸§ā¸Ŗ ({count})", @@ -471,6 +498,7 @@ "asset_action_share_err_offline": "āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸”ā¸ļā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸—ā¸Ŗā¸ąā¸žā¸ĸā¸˛ā¸ā¸Ŗā¸­ā¸­ā¸Ÿāš„ā¸Ĩā¸™āšŒ ⏁⏺ā¸Ĩā¸ąā¸‡ā¸‚āš‰ā¸˛ā¸Ą", "asset_added_to_album": "āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšā¸Ĩāš‰ā¸§", "asset_adding_to_album": "⏁⏺ā¸Ĩā¸ąā¸‡āš€ā¸žā¸´āšˆā¸Ąāš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąâ€Ļ", + "asset_created": "ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸Ēā¸ˇāšˆā¸­āšā¸Ĩāš‰ā¸§", "asset_description_updated": "ā¸­ā¸ąā¸›āš€ā¸”ā¸•ā¸Ŗā¸˛ā¸ĸā¸Ĩā¸°āš€ā¸­ā¸ĩā¸ĸ⏔ā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ", "asset_filename_is_offline": "ā¸Ēā¸ˇāšˆā¸­ {filename} ā¸­ā¸­ā¸Ÿāš„ā¸Ĩā¸™āšŒā¸­ā¸ĸā¸šāšˆ", "asset_has_unassigned_faces": "ā¸Ēā¸ˇāšˆā¸­āš„ā¸Ąāšˆāš„ā¸”āš‰ā¸Ŗā¸°ā¸šā¸¸āšƒā¸šā¸Ģā¸™āš‰ā¸˛", @@ -483,28 +511,35 @@ "asset_list_layout_sub_title": "ā¸ā¸˛ā¸Ŗā¸ˆā¸ąā¸”ā¸§ā¸˛ā¸‡", "asset_list_settings_subtitle": "ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸ā¸˛ā¸Ŗā¸ˆā¸ąā¸”ā¸§ā¸˛ā¸‡ā¸•ā¸˛ā¸Ŗā¸˛ā¸‡ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž", "asset_list_settings_title": "ā¸•ā¸˛ā¸Ŗā¸˛ā¸‡ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž", + "asset_not_found_on_device_android": "āš„ā¸Ąāšˆā¸žā¸šā¸Ēā¸ˇāšˆā¸­ā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒ", + "asset_not_found_on_device_ios": "āš„ā¸Ąāšˆā¸žā¸šā¸Ēā¸ˇāšˆā¸­ā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒ ā¸Ģā¸˛ā¸ā¸„ā¸¸ā¸“āšƒā¸Šāš‰ iCloud ā¸Ēā¸ˇāšˆā¸­ā¸­ā¸˛ā¸ˆā¸ˆā¸°āš€ā¸‚āš‰ā¸˛ā¸–ā¸ļā¸‡āš„ā¸Ąāšˆāš„ā¸”āš‰āš€ā¸™ā¸ˇāšˆā¸­ā¸‡ā¸ˆā¸˛ā¸ iCloud āš€ā¸āš‡ā¸šāš„ā¸Ÿā¸ĨāšŒā¸—ā¸ĩāšˆāš„ā¸Ąāšˆā¸”ā¸ĩāš„ā¸§āš‰", + "asset_not_found_on_icloud": "āš„ā¸Ąāšˆā¸žā¸šā¸Ēā¸ˇāšˆā¸­ā¸šā¸™ iCloud ā¸Ēā¸ˇāšˆā¸­ā¸­ā¸˛ā¸ˆā¸ˆā¸°āš€ā¸‚āš‰ā¸˛ā¸–ā¸ļā¸‡āš„ā¸Ąāšˆāš„ā¸”āš‰āš€ā¸™ā¸ˇāšˆā¸­ā¸‡ā¸ˆā¸˛ā¸ iCloud āš€ā¸āš‡ā¸šāš„ā¸Ÿā¸ĨāšŒā¸—ā¸ĩāšˆāš„ā¸Ąāšˆā¸”ā¸ĩāš„ā¸§āš‰", "asset_offline": "ā¸Ēā¸ˇāšˆā¸­ā¸­ā¸­ā¸Ÿāš„ā¸Ĩā¸™āšŒ", "asset_offline_description": "āš„ā¸Ąāšˆā¸žā¸šā¸—ā¸Ŗā¸ąā¸žā¸ĸ⏞⏁⏪⏠⏞ā¸ĸ⏙⏭⏁⏙ā¸ĩāš‰āšƒā¸™ā¸”ā¸´ā¸Ēā¸āšŒā¸­ā¸ĩā¸ā¸•āšˆā¸­āš„ā¸› āš‚ā¸›ā¸Ŗā¸”ā¸•ā¸´ā¸”ā¸•āšˆā¸­ā¸œā¸šāš‰ā¸”ā¸šāšā¸Ĩ⏪⏰⏚⏚ Immich ā¸‚ā¸­ā¸‡ā¸„ā¸¸ā¸“āš€ā¸žā¸ˇāšˆā¸­ā¸‚ā¸­ā¸„ā¸§ā¸˛ā¸Ąā¸Šāšˆā¸§ā¸ĸāš€ā¸Ģā¸Ĩ⏎⏭", "asset_restored_successfully": "ā¸ā¸šāš‰ā¸„ā¸ˇā¸™ā¸Ēā¸ˇāšˆā¸­ā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ", "asset_skipped": "ā¸‚āš‰ā¸˛ā¸Ąāšā¸Ĩāš‰ā¸§", "asset_skipped_in_trash": "āšƒā¸™ā¸–ā¸ąā¸‡ā¸‚ā¸ĸ⏰", + "asset_trashed": "ā¸ĸāš‰ā¸˛ā¸ĸā¸Ēā¸ˇāšˆā¸­āš„ā¸›ā¸ĸā¸ąā¸‡ā¸–ā¸ąā¸‡ā¸‚ā¸ĸā¸°āšā¸Ĩāš‰ā¸§", + "asset_troubleshoot": "āšā¸āš‰ā¸›ā¸ąā¸ā¸Ģ⏞ā¸Ēā¸ˇāšˆā¸­", "asset_uploaded": "ā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”āšā¸Ĩāš‰ā¸§", "asset_uploading": "⏁⏺ā¸Ĩā¸ąā¸‡ā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩ⏔â€Ļ", "asset_viewer_settings_subtitle": "ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸ā¸˛ā¸Ŗāšā¸Ēā¸”ā¸‡āšā¸ā¸Ĩāš€ā¸Ĩ⏭⏪ā¸ĩ", "asset_viewer_settings_title": "ā¸•ā¸ąā¸§ā¸”ā¸šā¸—ā¸Ŗā¸ąā¸žā¸ĸ⏞⏁⏪", "assets": "ā¸Ēā¸ˇāšˆā¸­", - "assets_added_count": "āš€ā¸žā¸´āšˆā¸Ą {count, plural, one{# ā¸Ēā¸ˇāšˆā¸­} other {# ā¸Ēā¸ˇāšˆā¸­}} āšā¸Ĩāš‰ā¸§", + "assets_added_count": "āš€ā¸žā¸´āšˆā¸Ąā¸Ēā¸ˇāšˆā¸­ {count, plural, one{# ⏪⏞ā¸ĸ⏁⏞⏪} other {# ⏪⏞ā¸ĸ⏁⏞⏪}}āšā¸Ĩāš‰ā¸§", "assets_added_to_album_count": "āš€ā¸žā¸´āšˆā¸Ą {count, plural, one {# asset} other {# assets}} āš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą", + "assets_added_to_albums_count": "āš€ā¸žā¸´āšˆā¸Ąā¸Ēā¸ˇāšˆā¸­ {assetTotal, plural, one {# ⏪⏞ā¸ĸ⏁⏞⏪} other {# ⏪⏞ā¸ĸ⏁⏞⏪}} āš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą {albumTotal, plural, one {# ⏪⏞ā¸ĸ⏁⏞⏪} other {# ⏪⏞ā¸ĸ⏁⏞⏪}}āšā¸Ĩāš‰ā¸§", "assets_cannot_be_added_to_album_count": "āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–āš€ā¸žā¸´āšˆā¸Ą {count, plural, one {ā¸Ēā¸ˇāšˆā¸­} other {ā¸Ēā¸ˇāšˆā¸­}} āš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ą", - "assets_count": "{count, plural, one { ā¸Ēā¸ˇāšˆā¸­} other { ā¸Ēā¸ˇāšˆā¸­}}", + "assets_cannot_be_added_to_albums": "āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–āš€ā¸žā¸´āšˆā¸Ą{count, plural, one {ā¸Ēā¸ˇāšˆā¸­} other {ā¸Ēā¸ˇāšˆā¸­}}āš„ā¸›ā¸ĸā¸ąā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšƒā¸” āš† āš„ā¸”āš‰", + "assets_count": "ā¸Ēā¸ˇāšˆā¸­ {count, plural, one {# ⏪⏞ā¸ĸ⏁⏞⏪} other {# ⏪⏞ā¸ĸ⏁⏞⏪}}", "assets_deleted_permanently": "{count} ā¸Ēā¸ˇāšˆā¸­ā¸–ā¸šā¸ā¸Ĩ⏚⏭ā¸ĸāšˆā¸˛ā¸‡ā¸–ā¸˛ā¸§ā¸Ŗ", - "assets_deleted_permanently_from_server": "ā¸Ĩ⏚ {count} ā¸Ēā¸ˇāšˆā¸­ā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ Immich ⏭ā¸ĸāšˆā¸˛ā¸‡ā¸–ā¸˛ā¸§ā¸Ŗ", + "assets_deleted_permanently_from_server": "ā¸Ĩ⏚ā¸Ēā¸ˇāšˆā¸­ {count} ⏪⏞ā¸ĸā¸ā¸˛ā¸Ŗā¸­ā¸­ā¸ā¸ˆā¸˛ā¸āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ Immich ⏭ā¸ĸāšˆā¸˛ā¸‡ā¸–ā¸˛ā¸§ā¸Ŗāšā¸Ĩāš‰ā¸§", "assets_downloaded_failed": "ā¸”ā¸˛ā¸§ā¸™āšŒāš‚ā¸Ģā¸Ĩ⏔ {count, plural, one {āš„ā¸Ÿā¸ĨāšŒ} other {āš„ā¸Ÿā¸ĨāšŒ}} āš„ā¸Ąāšˆā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ - {error}", "assets_downloaded_successfully": "ā¸”ā¸˛ā¸§ā¸™āšŒāš‚ā¸Ģā¸Ĩ⏔ {count, plural, one {āš„ā¸Ÿā¸ĨāšŒ} other {āš„ā¸Ÿā¸ĨāšŒ}} ā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ", "assets_moved_to_trash_count": "ā¸ĸāš‰ā¸˛ā¸ĸ {count, plural, one {# asset} other {# assets}} āš„ā¸›ā¸ĸā¸ąā¸‡ā¸–ā¸ąā¸‡ā¸‚ā¸ĸā¸°āšā¸Ĩāš‰ā¸§", "assets_permanently_deleted_count": "ā¸Ĩ⏚ {count, plural, one {# asset} other {# assets}} ā¸—ā¸´āš‰ā¸‡ā¸–ā¸˛ā¸§ā¸Ŗ", "assets_removed_count": "{count, plural, one {# asset} other {# assets}} ā¸–ā¸šā¸ā¸Ĩā¸šāšā¸Ĩāš‰ā¸§", - "assets_removed_permanently_from_device": "⏙⏺ {count} ā¸Ēā¸ˇāšˆā¸­ā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸­ā¸ĸāšˆā¸˛ā¸‡ā¸–ā¸˛ā¸§ā¸Ŗ", + "assets_removed_permanently_from_device": "ā¸Ĩ⏚ā¸Ēā¸ˇāšˆā¸­ {count} ⏪⏞ā¸ĸā¸ā¸˛ā¸Ŗā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸‚ā¸­ā¸‡ā¸„ā¸¸ā¸“ā¸­ā¸ĸāšˆā¸˛ā¸‡ā¸–ā¸˛ā¸§ā¸Ŗāšā¸Ĩāš‰ā¸§", "assets_restore_confirmation": "ā¸„ā¸¸ā¸“āšā¸™āšˆāšƒā¸ˆā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆā¸§āšˆā¸˛ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸ā¸šāš‰ā¸„ā¸ˇā¸™ā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆā¸—ā¸´āš‰ā¸‡ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”? ā¸„ā¸¸ā¸“āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸ĸāš‰ā¸­ā¸™ā¸ā¸Ĩā¸ąā¸šā¸ā¸˛ā¸Ŗā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗā¸™ā¸ĩāš‰āš„ā¸”āš‰! āš‚ā¸›ā¸Ŗā¸”ā¸—ā¸Ŗā¸˛ā¸šā¸§āšˆā¸˛ā¸Ēā¸ˇāšˆā¸­ā¸­ā¸­ā¸Ÿāš„ā¸Ĩā¸™āšŒāšƒā¸”āš† āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸ā¸šāš‰ā¸„ā¸ˇā¸™āš„ā¸”āš‰ā¸”āš‰ā¸§ā¸ĸ⏧⏴⏘ā¸ĩ⏙ā¸ĩāš‰", "assets_restored_count": "{count, plural, one {# asset} other {# assets}} ā¸„ā¸ˇā¸™ā¸„āšˆā¸˛", "assets_restored_successfully": "ā¸ā¸šāš‰ā¸„ā¸ˇā¸™ {count} ā¸Ēā¸ˇāšˆā¸­ā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ", @@ -512,14 +547,17 @@ "assets_trashed_count": "{count, plural, one {# asset} other {# assets}} ā¸–ā¸šā¸ā¸Ĩ⏚", "assets_trashed_from_server": "ā¸ĸāš‰ā¸˛ā¸ĸ {count} ā¸Ēā¸ˇāšˆā¸­ā¸ˆā¸˛ā¸ Immich āš„ā¸›ā¸ĸā¸ąā¸‡ā¸–ā¸ąā¸‡ā¸‚ā¸ĸ⏰", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} ⏭ā¸ĸā¸šāšˆāšƒā¸™ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸­ā¸ĸā¸šāšˆāšā¸Ĩāš‰ā¸§", + "assets_were_part_of_albums_count": "{count, plural, one {ā¸Ēā¸ˇāšˆā¸­} other {ā¸Ēā¸ˇāšˆā¸­}}āš€ā¸›āš‡ā¸™ā¸Ēāšˆā¸§ā¸™ā¸Ģ⏙ā¸ļāšˆā¸‡ā¸‚ā¸­ā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸­ā¸ĸā¸šāšˆāšā¸Ĩāš‰ā¸§", "authorized_devices": "ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸—ā¸ĩāšˆāš„ā¸”āš‰ā¸Ŗā¸ąā¸šā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•", "automatic_endpoint_switching_subtitle": "āš€ā¸Šā¸ˇāšˆā¸­ā¸Ąā¸•āšˆā¸­ā¸”āš‰ā¸§ā¸ĸ LAN ⏠⏞ā¸ĸāšƒā¸™ā¸§ā¸‡ Wi-Fi ⏗ā¸ĩāšˆā¸Ŗā¸°ā¸šā¸¸āš„ā¸§āš‰ āšā¸Ĩā¸°āš€ā¸Šā¸ˇāšˆā¸­ā¸Ąā¸•āšˆā¸­ā¸”āš‰ā¸§ā¸ĸ⏧⏴⏘ā¸ĩā¸­ā¸ˇāšˆā¸™āš€ā¸Ąā¸ˇāšˆā¸­ā¸­ā¸ĸā¸šāšˆā¸™ā¸­ā¸ Wi-Fi ⏗ā¸ĩāšˆā¸Ŗā¸°ā¸šā¸¸āš„ā¸§āš‰", "automatic_endpoint_switching_title": "ā¸Ēā¸Ĩā¸ąā¸š URL ā¸­ā¸ąā¸•āš‚ā¸™ā¸Ąā¸ąā¸•ā¸´", "autoplay_slideshow": "āš€ā¸Ĩāšˆā¸™ā¸Ēāš„ā¸Ĩā¸”āšŒāš‚ā¸Šā¸§āšŒ", "back": "⏁ā¸Ĩā¸ąā¸š", "back_close_deselect": "ā¸ĸāš‰ā¸­ā¸™ā¸ā¸Ĩā¸ąā¸š, ⏛⏴⏔, ā¸Ģ⏪⏎⏭ā¸ĸā¸āš€ā¸Ĩā¸´ā¸ā¸ā¸˛ā¸Ŗāš€ā¸Ĩ⏎⏭⏁", + "background_backup_running_error": "⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāš€ā¸šā¸ˇāš‰ā¸­ā¸‡ā¸Ģā¸Ĩā¸ąā¸‡ā¸—ā¸ŗā¸‡ā¸˛ā¸™ā¸­ā¸ĸā¸šāšˆ āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–āš€ā¸Ŗā¸´āšˆā¸Ąā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸”āš‰ā¸§ā¸ĸā¸•ā¸™āš€ā¸­ā¸‡āš„ā¸”āš‰", "background_location_permission": "ā¸ā¸˛ā¸Ŗā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•ā¸Ŗā¸°ā¸šā¸¸ā¸•ā¸ŗāšā¸Ģā¸™āšˆā¸‡ā¸žā¸ˇāš‰ā¸™ā¸Ģā¸Ĩā¸ąā¸‡", "background_location_permission_content": "āš€ā¸žā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆā¸ˆā¸°ā¸Ēā¸Ĩā¸ąā¸šā¸ā¸˛ā¸Ŗāš€ā¸Šā¸ˇāšˆā¸­ā¸Ąā¸•āšˆā¸­ā¸‚ā¸“ā¸°ā¸—ā¸ĩāšˆā¸Ŗā¸ąā¸™āšƒā¸™ā¸žā¸ˇāš‰ā¸™ā¸Ģā¸Ĩā¸ąā¸‡ Immich ā¸•āš‰ā¸­ā¸‡ā¸Ŗā¸šāš‰ā¸•ā¸ŗāšā¸Ģā¸™āšˆā¸‡ā¸—ā¸ĩāšˆāšā¸Ąāšˆā¸ĸ⏺⏕ā¸Ĩā¸­ā¸”āš€ā¸§ā¸Ĩ⏞ āš€ā¸žā¸ˇāšˆā¸­ā¸ˆā¸°ā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸­āšˆā¸˛ā¸™ā¸Šā¸ˇāšˆā¸­ Wi-Fi", + "background_options": "ā¸•ā¸ąā¸§āš€ā¸Ĩā¸ˇā¸­ā¸ā¸ā¸˛ā¸Ŗā¸—ā¸ŗā¸‡ā¸˛ā¸™āš€ā¸šā¸ˇāš‰ā¸­ā¸‡ā¸Ģā¸Ĩā¸ąā¸‡", "backup": "ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ", "backup_album_selection_page_albums_device": "ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸šā¸™āš€ā¸„ā¸Ŗā¸ˇāšˆā¸­ā¸‡ ({count})", "backup_album_selection_page_albums_tap": "ā¸ā¸”āš€ā¸žā¸ˇāšˆā¸­ā¸Ŗā¸§ā¸Ą ⏁⏔ā¸Ēā¸­ā¸‡ā¸„ā¸Ŗā¸ąāš‰ā¸‡āš€ā¸žā¸ˇāšˆā¸­ā¸ĸā¸āš€ā¸§āš‰ā¸™", @@ -581,8 +619,11 @@ "backup_manual_in_progress": "ā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩ⏔⏁⏺ā¸Ĩā¸ąā¸‡ā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗā¸­ā¸ĸā¸šāšˆ āš‚ā¸›ā¸Ŗā¸”ā¸Ĩā¸­ā¸‡āšƒā¸Ģā¸Ąāšˆāšƒā¸™ā¸Ēā¸ąā¸ā¸žā¸ąā¸", "backup_manual_success": "ā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ", "backup_manual_title": "ā¸Ēā¸–ā¸˛ā¸™ā¸°ā¸­ā¸ąā¸žāš‚ā¸Ģā¸Ĩ⏔", + "backup_options": "ā¸•ā¸ąā¸§āš€ā¸Ĩ⏎⏭⏁⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ", "backup_options_page_title": "ā¸•ā¸ąā¸§āš€ā¸Ĩ⏎⏭⏁⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩ", "backup_setting_subtitle": "ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸ā¸˛ā¸Ŗā¸­ā¸ąā¸žāš‚ā¸Ģā¸Ĩā¸”āšƒā¸™ā¸‰ā¸˛ā¸ā¸Ģā¸™āš‰ā¸˛ āšā¸Ĩā¸°ā¸žā¸ˇāš‰ā¸™ā¸Ģā¸Ĩā¸ąā¸‡", + "backup_settings_subtitle": "ā¸ˆā¸ąā¸”ā¸ā¸˛ā¸Ŗā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩ⏔", + "backup_upload_details_page_more_details": "āšā¸•ā¸°āš€ā¸žā¸ˇāšˆā¸­ā¸”ā¸šā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāš€ā¸žā¸´āšˆā¸Ąāš€ā¸•ā¸´ā¸Ą", "backward": "⏁ā¸Ĩā¸ąā¸šā¸Ģā¸Ĩā¸ąā¸‡", "biometric_auth_enabled": "ā¸ā¸˛ā¸Ŗā¸žā¸´ā¸Ēā¸šā¸ˆā¸™āšŒā¸­ā¸ąā¸•ā¸Ĩā¸ąā¸ā¸Šā¸“āšŒāš€ā¸žā¸ˇāšˆā¸­ā¸ĸ⏎⏙ā¸ĸā¸ąā¸™ā¸•ā¸ąā¸§ā¸šā¸¸ā¸„ā¸„ā¸Ĩā¸–ā¸šā¸āš€ā¸›ā¸´ā¸”", "biometric_locked_out": "ā¸ā¸˛ā¸Ŗā¸žā¸´ā¸Ēā¸šā¸ˆā¸™āšŒā¸­ā¸ąā¸•ā¸Ĩā¸ąā¸ā¸Šā¸“āšŒāš€ā¸žā¸ˇāšˆā¸­ā¸ĸ⏎⏙ā¸ĸā¸ąā¸™ā¸•ā¸ąā¸§ā¸šā¸¸ā¸„ā¸„ā¸Ĩā¸–ā¸šā¸ā¸Ĩāš‡ā¸­ā¸„", @@ -618,6 +659,7 @@ "cancel": "ā¸ĸā¸āš€ā¸Ĩ⏴⏁", "cancel_search": "ā¸ĸā¸āš€ā¸Ĩā¸´ā¸ā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģ⏞", "canceled": "ā¸ĸā¸āš€ā¸Ĩ⏴⏁", + "canceling": "⏁⏺ā¸Ĩā¸ąā¸‡ā¸ĸā¸āš€ā¸Ĩ⏴⏁", "cannot_merge_people": "āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸Ŗā¸§ā¸Ąā¸ā¸Ĩā¸¸āšˆā¸Ąā¸„ā¸™āš„ā¸”āš‰", "cannot_undo_this_action": "⏁⏞⏪⏁⏪⏰⏗⏺⏙ā¸ĩāš‰āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸ĸāš‰ā¸­ā¸™ā¸ā¸Ĩā¸ąā¸šāš„ā¸”āš‰!", "cannot_update_the_description": "āš„ā¸Ąāšˆā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸­ā¸ąā¸žāš€ā¸”ā¸—ā¸Ŗā¸˛ā¸ĸā¸Ĩā¸°āš€ā¸­ā¸ĩā¸ĸā¸”āš„ā¸”āš‰", @@ -634,18 +676,31 @@ "change_password_description": "ā¸ā¸˛ā¸Ŗāš€ā¸‚āš‰ā¸˛ā¸Ēā¸šāšˆā¸Ŗā¸°ā¸šā¸šā¸„ā¸Ŗā¸ąāš‰ā¸‡āšā¸Ŗā¸ ā¸ˆā¸ŗāš€ā¸›āš‡ā¸™ā¸ˆā¸•āš‰ā¸­ā¸‡āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸ⏙⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™ā¸‚ā¸­ā¸‡ā¸„ā¸¸ā¸“āš€ā¸žā¸ˇāšˆā¸­ā¸„ā¸§ā¸˛ā¸Ąā¸›ā¸Ĩā¸­ā¸”ā¸ ā¸ąā¸ĸ āš‚ā¸›ā¸Ŗā¸”ā¸›āš‰ā¸­ā¸™ā¸Ŗā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™āšƒā¸Ģā¸Ąāšˆā¸”āš‰ā¸˛ā¸™ā¸Ĩāšˆā¸˛ā¸‡", "change_password_form_confirm_password": "ā¸ĸ⏎⏙ā¸ĸā¸ąā¸™ā¸Ŗā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™", "change_password_form_description": "ā¸Ēā¸§ā¸ąā¸Ē⏔ā¸ĩ {name},\n\nā¸„ā¸Ŗā¸ąāš‰ā¸‡ā¸™ā¸ĩāš‰ā¸­ā¸˛ā¸ˆā¸ˆā¸°āš€ā¸›āš‡ā¸™ā¸„ā¸Ŗā¸ąāš‰ā¸‡āšā¸Ŗā¸ā¸—ā¸ĩāšˆā¸„ā¸¸ā¸“āš€ā¸‚āš‰ā¸˛ā¸Ēā¸šāšˆā¸Ŗā¸°ā¸šā¸š ā¸Ģā¸Ŗā¸ˇā¸­ā¸Ąā¸ĩā¸„ā¸ŗā¸‚ā¸­āš€ā¸žā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆā¸ˆā¸°āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸ⏙⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™ā¸‚ā¸­ā¸‡ā¸„ā¸¸I ā¸ā¸Ŗā¸¸ā¸“ā¸˛āš€ā¸žā¸´āšˆā¸Ąā¸Ŗā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™āšƒā¸Ģā¸Ąāšˆā¸‚āš‰ā¸˛ā¸‡ā¸Ĩāšˆā¸˛ā¸‡", + "change_password_form_log_out": "ā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸Ŗā¸°ā¸šā¸šā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸­ā¸ˇāšˆā¸™ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", + "change_password_form_log_out_description": "āšā¸™ā¸°ā¸™ā¸ŗāšƒā¸Ģāš‰ā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸Ŗā¸°ā¸šā¸šā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸­ā¸ˇāšˆā¸™ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”ā¸”āš‰ā¸§ā¸ĸ", "change_password_form_new_password": "⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™āšƒā¸Ģā¸Ąāšˆ", "change_password_form_password_mismatch": "⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™āš„ā¸Ąāšˆā¸•ā¸Ŗā¸‡ā¸ā¸ąā¸™", "change_password_form_reenter_new_password": "⏁⏪⏭⏁⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™āšƒā¸Ģā¸Ąāšˆ", "change_pin_code": "āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸ⏙⏪ā¸Ģā¸ąā¸Ēā¸›ā¸Ŗā¸°ā¸ˆā¸ŗā¸•ā¸ąā¸§ (PIN)", "change_your_password": "āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸ⏙⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™ā¸‚ā¸­ā¸‡ā¸„ā¸¸ā¸“", "changed_visibility_successfully": "āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸā¸™ā¸ā¸˛ā¸Ŗā¸Ąā¸­ā¸‡āš€ā¸Ģāš‡ā¸™āš€ā¸Ŗā¸ĩā¸ĸā¸šā¸Ŗāš‰ā¸­ā¸ĸāšā¸Ĩāš‰ā¸§", + "charging": "⏁⏺ā¸Ĩā¸ąā¸‡ā¸Šā¸˛ā¸ŖāšŒā¸ˆ", + "charging_requirement_mobile_backup": "⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšƒā¸™āš€ā¸šā¸ˇāš‰ā¸­ā¸‡ā¸Ģā¸Ĩā¸ąā¸‡ā¸ˆā¸°ā¸—ā¸ŗā¸‡ā¸˛ā¸™āš€ā¸‰ā¸žā¸˛ā¸°āš€ā¸Ąā¸ˇāšˆā¸­ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸ā¸ŗā¸Ĩā¸ąā¸‡ā¸Šā¸˛ā¸ŖāšŒā¸ˆā¸­ā¸ĸā¸šāšˆ", "check_corrupt_asset_backup": "ā¸•ā¸Ŗā¸§ā¸ˆā¸Ē⏭⏚ā¸Ē⏺⏪⏭⏇ā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆā¸œā¸´ā¸”ā¸›ā¸ā¸•ā¸´", "check_corrupt_asset_backup_button": "ā¸•ā¸Ŗā¸§ā¸ˆā¸Ē⏭⏚", "check_corrupt_asset_backup_description": "ā¸•ā¸Ŗā¸§ā¸ˆā¸Ēā¸­ā¸šāš€ā¸Ąā¸ˇāšˆā¸­āš€ā¸Šā¸ˇāšˆā¸­ā¸Ąā¸•āšˆā¸­ Wi-Fi āšā¸Ĩ⏰ā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”ā¸–ā¸šā¸ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšā¸Ĩāš‰ā¸§āš€ā¸—āšˆā¸˛ā¸™ā¸ąāš‰ā¸™ ā¸ā¸˛ā¸Ŗā¸•ā¸Ŗā¸§ā¸ˆā¸Ēā¸­ā¸šā¸­ā¸˛ā¸ˆāšƒā¸Šāš‰āš€ā¸§ā¸Ĩ⏞ā¸Ģā¸Ĩ⏞ā¸ĸ⏙⏞⏗ā¸ĩ", "check_logs": "ā¸•ā¸Ŗā¸§ā¸ˆā¸Ēā¸­ā¸šā¸šā¸ąā¸™ā¸—ā¸ļ⏁", "choose_matching_people_to_merge": "āš€ā¸Ĩ⏎⏭⏁⏄⏙⏗ā¸ĩāšˆā¸•ā¸Ŗā¸‡ā¸ā¸ąā¸™āš€ā¸žā¸ˇāšˆā¸­ā¸Ŗā¸§ā¸Ąāš€ā¸‚āš‰ā¸˛ā¸”āš‰ā¸§ā¸ĸā¸ā¸ąā¸™", "city": "āš€ā¸Ąā¸ˇā¸­ā¸‡", + "cleanup_confirm_description": "Immich ā¸žā¸šā¸Ēā¸ˇāšˆā¸­ {count} ⏪⏞ā¸ĸ⏁⏞⏪ (ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸‚ā¸ļāš‰ā¸™ā¸āšˆā¸­ā¸™ {date}) ⏗ā¸ĩāšˆā¸–ā¸šā¸ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸šā¸™āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒā¸­ā¸ĸāšˆā¸˛ā¸‡ā¸›ā¸Ĩā¸­ā¸”ā¸ ā¸ąā¸ĸāšā¸Ĩāš‰ā¸§ ā¸Ĩ⏚ā¸Ēā¸ŗāš€ā¸™ā¸˛ā¸•āš‰ā¸™ā¸—ā¸˛ā¸‡ā¸ˆā¸˛ā¸ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ?", + "cleanup_confirm_prompt_title": "ā¸Ĩā¸šā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ?", + "cleanup_deleted_assets": "ā¸ĸāš‰ā¸˛ā¸ĸā¸Ēā¸ˇāšˆā¸­ {count} ⏪⏞ā¸ĸā¸ā¸˛ā¸Ŗāš„ā¸›ā¸ĸā¸ąā¸‡ā¸–ā¸ąā¸‡ā¸‚ā¸ĸā¸°ā¸‚ā¸­ā¸‡ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒāšā¸Ĩāš‰ā¸§", + "cleanup_deleting": "⏁⏺ā¸Ĩā¸ąā¸‡ā¸ĸāš‰ā¸˛ā¸ĸāš„ā¸›ā¸–ā¸ąā¸‡ā¸‚ā¸ĸ⏰...", + "cleanup_found_assets": "ā¸žā¸šā¸Ēā¸ˇāšˆā¸­ {count} ⏪⏞ā¸ĸ⏁⏞⏪⏗ā¸ĩāšˆā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšā¸Ĩāš‰ā¸§", + "cleanup_found_assets_with_size": "ā¸žā¸šā¸Ēā¸ˇāšˆā¸­ {count} ⏪⏞ā¸ĸ⏁⏞⏪⏗ā¸ĩāšˆā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšā¸Ĩāš‰ā¸§ ({size})", + "cleanup_icloud_shared_albums_excluded": "ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸—ā¸ĩāšˆāšā¸Šā¸ŖāšŒā¸šā¸™ iCloud āš„ā¸Ąāšˆā¸™ā¸ąā¸šā¸Ŗā¸§ā¸Ąāšƒā¸™ā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģ⏞", + "cleanup_no_assets_found": "āš„ā¸Ąāšˆā¸žā¸šā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆā¸•ā¸Ŗā¸‡ā¸•ā¸˛ā¸Ąāš€ā¸‡ā¸ˇāšˆā¸­ā¸™āš„ā¸‚ā¸”āš‰ā¸˛ā¸™ā¸šā¸™ \"āš€ā¸žā¸´āšˆā¸Ąā¸žā¸ˇāš‰ā¸™ā¸—ā¸ĩāšˆā¸§āšˆā¸˛ā¸‡\" ā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸Ĩā¸šāš„ā¸”āš‰āš€ā¸‰ā¸žā¸˛ā¸°ā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸šā¸™āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒāš€ā¸Ŗā¸ĩā¸ĸā¸šā¸Ŗāš‰ā¸­ā¸ĸāšā¸Ĩāš‰ā¸§āš€ā¸—āšˆā¸˛ā¸™ā¸ąāš‰ā¸™", + "cleanup_preview_title": "ā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆā¸ˆā¸°ā¸Ĩ⏚ ({count})", "clear": "ā¸Ĩāš‰ā¸˛ā¸‡", "clear_all": "ā¸Ĩāš‰ā¸˛ā¸‡ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", "clear_all_recent_searches": "ā¸Ĩāš‰ā¸˛ā¸‡ā¸›ā¸Ŗā¸°ā¸§ā¸ąā¸•ā¸´ā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģ⏞", @@ -788,7 +843,7 @@ "display_original_photos_setting_description": "ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛āšā¸Ēā¸”ā¸‡ā¸œā¸Ĩā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸žā¸•āš‰ā¸™ā¸‰ā¸šā¸ąā¸š āš€ā¸Ąā¸ˇāšˆā¸­āš€ā¸›ā¸´ā¸”ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸™ā¸ĩāš‰ā¸­ā¸˛ā¸ˆā¸ˆā¸°ā¸—ā¸ŗāšƒā¸Ģāš‰ā¸ā¸˛ā¸Ŗāšā¸Ēā¸”ā¸‡ā¸ ā¸˛ā¸žāš„ā¸”āš‰ā¸Šāš‰ā¸˛ā¸Ĩ⏇", "do_not_show_again": "āš„ā¸Ąāšˆāšā¸Ēā¸”ā¸‡ā¸‚āš‰ā¸­ā¸„ā¸§ā¸˛ā¸Ąā¸™ā¸ĩāš‰ā¸­ā¸ĩ⏁", "documentation": "āš€ā¸­ā¸ā¸Ē⏞⏪", - "done": "ā¸”ā¸ŗāš€ā¸™ā¸´ā¸™ā¸ā¸˛ā¸Ŗā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ", + "done": "āš€ā¸Ēā¸Ŗāš‡ā¸ˆā¸Ēā¸´āš‰ā¸™", "download": "ā¸”ā¸˛ā¸§ā¸™āšŒāš‚ā¸Ģā¸Ĩ⏔", "download_action_prompt": "⏁⏺ā¸Ĩā¸ąā¸‡ā¸”ā¸˛ā¸§ā¸™āšŒāš‚ā¸Ģā¸Ĩ⏔ {count} ā¸Šā¸´āš‰ā¸™", "download_canceled": "ā¸ā¸˛ā¸Ŗā¸”ā¸˛ā¸§ā¸™āšŒāš‚ā¸Ģā¸Ĩ⏔ā¸ĸā¸āš€ā¸Ĩ⏴⏁", @@ -1009,7 +1064,7 @@ "export_as_json": "ā¸Ēāšˆā¸‡ā¸­ā¸­ā¸āš€ā¸›āš‡ā¸™āš„ā¸Ÿā¸ĨāšŒ JSON", "extension": "ā¸Ēāšˆā¸§ā¸™ā¸•āšˆā¸­ā¸‚ā¸ĸ⏞ā¸ĸ", "external": "⏠⏞ā¸ĸ⏙⏭⏁", - "external_libraries": "⏠⏞ā¸ĸ⏙⏭⏁⏄ā¸Ĩā¸ąā¸‡ā¸ ā¸˛ā¸ž", + "external_libraries": "⏄ā¸Ĩā¸ąā¸‡ā¸ ā¸˛ā¸žā¸ ā¸˛ā¸ĸ⏙⏭⏁", "external_network": "ā¸ā¸˛ā¸Ŗāš€ā¸Šā¸ˇāšˆā¸­ā¸Ąā¸•āšˆā¸­ā¸ ā¸˛ā¸ĸ⏙⏭⏁", "external_network_sheet_info": "āš€ā¸Ąā¸ˇāšˆā¸­āš„ā¸Ąāšˆāš„ā¸”āš‰āš€ā¸Šā¸ˇāšˆā¸­ā¸Ąā¸•āšˆā¸­ Wi-Fi ⏗ā¸ĩāšˆāš€ā¸Ĩā¸ˇā¸­ā¸āš„ā¸§āš‰ āšā¸­ā¸žā¸ˆā¸°āš€ā¸Šā¸ˇāšˆā¸­ā¸Ąā¸•āšˆā¸­āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒā¸œāšˆā¸˛ā¸™ URL ā¸”āš‰ā¸˛ā¸™ā¸Ĩāšˆā¸˛ā¸‡ā¸•ā¸˛ā¸Ąā¸Ĩā¸ŗā¸”ā¸ąā¸š", "face_unassigned": "āš„ā¸Ąāšˆā¸ā¸ŗā¸Ģā¸™ā¸”ā¸Ąā¸­ā¸šā¸Ģā¸Ąā¸˛ā¸ĸ", @@ -1140,6 +1195,7 @@ "keep": "āš€ā¸āš‡ā¸š", "keep_all": "āš€ā¸āš‡ā¸šā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", "keep_description": "āš€ā¸Ĩ⏎⏭⏁ā¸Ēā¸´āšˆā¸‡ā¸—ā¸ĩāšˆā¸ˆā¸°āš€ā¸āš‡ā¸šāš„ā¸§āš‰ā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸‚ā¸­ā¸‡ā¸„ā¸¸ā¸“ā¸‚ā¸“ā¸°āš€ā¸žā¸´āšˆā¸Ąā¸žā¸ˇāš‰ā¸™ā¸—ā¸ĩāšˆā¸§āšˆā¸˛ā¸‡", + "keep_on_device_hint": "āš€ā¸Ĩ⏎⏭⏁⏪⏞ā¸ĸ⏁⏞⏪⏗ā¸ĩāšˆā¸ˆā¸°āš€ā¸āš‡ā¸šāš„ā¸§āš‰ā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰", "keep_this_delete_others": "āš€ā¸āš‡ā¸šā¸Ēā¸´āšˆā¸‡ā¸™ā¸ĩāš‰āš„ā¸§āš‰ ā¸Ĩā¸šā¸­ā¸ąā¸™ā¸­ā¸ˇāšˆā¸™ā¸­ā¸­ā¸", "kept_this_deleted_others": "āš€ā¸āš‡ā¸šāš€ā¸™ā¸ˇāš‰ā¸­ā¸Ģ⏞⏙ā¸ĩāš‰āšā¸Ĩ⏰ā¸Ĩ⏚ {count, plural, one {# Asset} other {# Asset}}", "keyboard_shortcuts": "ā¸›ā¸¸āšˆā¸Ąā¸žā¸´ā¸Ąā¸žāšŒā¸Ĩā¸ąā¸”", @@ -1213,7 +1269,7 @@ "login_password_changed_error": "āš€ā¸ā¸´ā¸”ā¸‚āš‰ā¸­ā¸œā¸´ā¸”ā¸žā¸Ĩā¸˛ā¸”ā¸‚ā¸“ā¸°āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸ⏙⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™", "login_password_changed_success": "āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸ⏙⏪ā¸Ģā¸ąā¸Ēā¸œāšˆā¸˛ā¸™ā¸Ēā¸ŗāš€ā¸Ŗāš‡ā¸ˆ", "logout_all_device_confirmation": "ā¸„ā¸¸ā¸“ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸Ŗā¸°ā¸šā¸šā¸—ā¸¸ā¸ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒ āšƒā¸Šāšˆā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ ?", - "logout_this_device_confirmation": "ā¸„ā¸¸ā¸“ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸Ŗā¸°ā¸šā¸šāšƒā¸Šāšˆā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ ?", + "logout_this_device_confirmation": "ā¸„ā¸¸ā¸“ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗā¸­ā¸­ā¸ā¸ˆā¸˛ā¸ā¸Ŗā¸°ā¸šā¸šā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰ā¸Ģā¸Ŗā¸ˇā¸­āš„ā¸Ąāšˆ?", "longitude": "ā¸Ĩā¸­ā¸‡ā¸ˆā¸´ā¸ˆā¸šā¸”", "look": "ā¸”ā¸š", "loop_videos": "⏧⏙⏧⏴⏔ā¸ĩāš‚ā¸­", @@ -1315,7 +1371,6 @@ "no_results_description": "ā¸Ĩā¸­ā¸‡āšƒā¸Šāš‰ā¸„ā¸ŗā¸žāš‰ā¸­ā¸‡ā¸Ģ⏪⏎⏭⏄⏺ā¸Ģā¸Ĩā¸ąā¸ā¸—ā¸ĩāšˆā¸ā¸§āš‰ā¸˛ā¸‡ā¸ā¸§āšˆā¸˛ā¸™ā¸ĩāš‰", "no_shared_albums_message": "ā¸Ēā¸Ŗāš‰ā¸˛ā¸‡ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāš€ā¸žā¸ˇāšˆā¸­āšā¸Šā¸ŖāšŒā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸žāšā¸Ĩ⏰⏧⏴⏔ā¸ĩāš‚ā¸­ā¸ā¸ąā¸šā¸„ā¸™āšƒā¸™āš€ā¸„ā¸Ŗā¸ˇā¸­ā¸‚āšˆā¸˛ā¸ĸ⏂⏭⏇⏄⏏⏓", "not_in_any_album": "āš„ā¸Ąāšˆā¸­ā¸ĸā¸šāšˆāšƒā¸™ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąāšƒā¸” āš†", - "note_apply_storage_label_to_previously_uploaded assets": "ā¸Ģā¸Ąā¸˛ā¸ĸāš€ā¸Ģ⏕⏏: ā¸Ģā¸˛ā¸ā¸•āš‰ā¸­ā¸‡ā¸ā¸˛ā¸Ŗāšƒā¸Šāš‰ā¸›āš‰ā¸˛ā¸ĸā¸ā¸ŗā¸ā¸ąā¸šā¸žā¸ˇāš‰ā¸™ā¸—ā¸ĩāšˆāš€ā¸āš‡ā¸šā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸ā¸ąā¸šāš€ā¸™ā¸ˇāš‰ā¸­ā¸Ģ⏞⏗ā¸ĩāšˆā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”ā¸āšˆā¸­ā¸™ā¸Ģā¸™āš‰ā¸˛ā¸™ā¸ĩāš‰ āšƒā¸Ģāš‰āš€ā¸Ŗā¸ĩā¸ĸā¸āšƒā¸Šāš‰", "notes": "ā¸Ģā¸Ąā¸˛ā¸ĸāš€ā¸Ģ⏕⏏", "notification_permission_dialog_content": "āš€ā¸žā¸ˇāšˆā¸­āš€ā¸›ā¸´ā¸”ā¸ā¸˛ā¸Ŗāšā¸ˆāš‰ā¸‡āš€ā¸•ā¸ˇā¸­ā¸™ āš€ā¸‚āš‰ā¸˛ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛āšā¸Ĩāš‰ā¸§ā¸ā¸”ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•", "notification_permission_list_tile_content": "ā¸­ā¸™ā¸¸ā¸ā¸˛ā¸•ā¸ā¸˛ā¸Ŗāšā¸ˆāš‰ā¸‡āš€ā¸•ā¸ˇā¸­ā¸™", @@ -1328,6 +1383,7 @@ "offline": "ā¸­ā¸­ā¸Ÿāš„ā¸Ĩā¸™āšŒ", "ok": "⏕⏁ā¸Ĩ⏇", "oldest_first": "āš€ā¸Ŗā¸ĩā¸ĸā¸‡āš€ā¸āšˆā¸˛ā¸Ēā¸¸ā¸”ā¸āšˆā¸­ā¸™", + "on_this_device": "ā¸šā¸™ā¸­ā¸¸ā¸›ā¸ā¸Ŗā¸“āšŒā¸™ā¸ĩāš‰", "onboarding": "ā¸ā¸˛ā¸Ŗāš€ā¸Ŗā¸´āšˆā¸Ąā¸•āš‰ā¸™āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™", "onboarding_privacy_description": "⏟ā¸ĩāš€ā¸ˆā¸­ā¸ŖāšŒ (ā¸•ā¸ąā¸§āš€ā¸Ĩ⏎⏭⏁) ā¸•āšˆā¸­āš„ā¸›ā¸™ā¸ĩāš‰ā¸•āš‰ā¸­ā¸‡ā¸­ā¸˛ā¸¨ā¸ąā¸ĸ⏚⏪⏴⏁⏞⏪⏠⏞ā¸ĸ⏙⏭⏁ āšā¸Ĩ⏰ā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–ā¸›ā¸´ā¸”āšƒā¸Šāš‰ā¸‡ā¸˛ā¸™āš„ā¸”āš‰ā¸•ā¸Ĩā¸­ā¸”āš€ā¸§ā¸Ĩā¸˛āšƒā¸™ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸ā¸˛ā¸Ŗ", "onboarding_theme_description": "āš€ā¸Ĩ⏎⏭⏁⏘ā¸ĩā¸Ąā¸Ēā¸ĩ ⏄⏏⏓ā¸Ēā¸˛ā¸Ąā¸˛ā¸Ŗā¸–āš€ā¸›ā¸Ĩā¸ĩāšˆā¸ĸā¸™āšā¸›ā¸Ĩā¸‡āš„ā¸”āš‰āšƒā¸™ā¸ ā¸˛ā¸ĸā¸Ģā¸Ĩā¸ąā¸‡āšƒā¸™ā¸ā¸˛ā¸Ŗā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸‚ā¸­ā¸‡ā¸„ā¸¸ā¸“", @@ -1395,7 +1451,8 @@ "permission_onboarding_permission_limited": "ā¸Ēā¸´ā¸—ā¸˜āšŒā¸ˆā¸ŗā¸ā¸ąā¸” āš€ā¸žā¸ˇāšˆā¸­āšƒā¸Ģāš‰ Immich ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšā¸Ĩā¸°ā¸ˆā¸ąā¸”ā¸ā¸˛ā¸Ŗā¸„ā¸Ĩā¸ąā¸‡ā¸ ā¸˛ā¸žāš„ā¸”āš‰ ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸Ēā¸´ā¸—ā¸˜ā¸´āš€ā¸‚āš‰ā¸˛ā¸–ā¸ļā¸‡ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸žāšā¸Ĩ⏰⏧⏴⏔ā¸ĩāš‚ā¸­", "permission_onboarding_request": "Immich ā¸ˆā¸ŗāš€ā¸›āš‡ā¸™ā¸ˆā¸°ā¸•āš‰ā¸­ā¸‡āš„ā¸”āš‰ā¸Ŗā¸ąā¸šā¸Ēā¸´ā¸—ā¸˜ā¸´āšŒā¸”ā¸šā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸žāšā¸Ĩ⏰⏧⏴⏔ā¸ĩāš‚ā¸­", "person": "ā¸šā¸¸ā¸„ā¸„ā¸Ĩ", - "person_birthdate": "āš€ā¸ā¸´ā¸”ā¸§ā¸ąā¸™{date}", + "person_age_years": "⏭⏞ā¸ĸ⏏ {years, plural, other {# ⏛ā¸ĩ}}", + "person_birthdate": "āš€ā¸ā¸´ā¸”āš€ā¸Ąā¸ˇāšˆā¸­ {date}", "photo_shared_all_users": "ā¸”ā¸šāš€ā¸Ģā¸Ąā¸ˇā¸­ā¸™ā¸§āšˆā¸˛ā¸„ā¸¸ā¸“āš„ā¸”āš‰āšā¸Šā¸ŖāšŒā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸žā¸‚ā¸­ā¸‡ā¸„ā¸¸ā¸“ā¸ā¸ąā¸šā¸œā¸šāš‰āšƒā¸Šāš‰ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸” ā¸Ģā¸Ŗā¸ˇā¸­ā¸„ā¸¸ā¸“āš„ā¸Ąāšˆā¸Ąā¸ĩā¸œā¸šāš‰āšƒā¸Šāš‰āšƒā¸”ā¸—ā¸ĩāšˆā¸ˆā¸°āšā¸Šā¸ŖāšŒā¸”āš‰ā¸§ā¸ĸ", "photos": "ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž", "photos_and_videos": "ā¸Ŗā¸šā¸›ā¸ ā¸˛ā¸ž āšā¸Ĩ⏰ ⏧⏴⏔ā¸ĩāš‚ā¸­", @@ -1469,7 +1526,7 @@ "reassigned_assets_to_new_person": "ā¸Ąā¸­ā¸šā¸Ģā¸Ąā¸˛ā¸ĸ {count, plural, one {# ā¸Ēā¸ˇāšˆā¸­} other {# ā¸Ēā¸ˇāšˆā¸­}} āšƒā¸Ģāš‰ā¸ā¸ąā¸šā¸šā¸¸ā¸„ā¸„ā¸Ĩāšƒā¸Ģā¸Ąāšˆ", "reassing_hint": "ā¸Ąā¸­ā¸šā¸Ģā¸Ąā¸˛ā¸ĸā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ĩāšˆāš€ā¸Ĩā¸ˇā¸­ā¸āšƒā¸Ģāš‰ā¸ā¸ąā¸šā¸šā¸¸ā¸„ā¸„ā¸Ĩ⏗ā¸ĩāšˆā¸Ąā¸ĩ⏭ā¸ĸā¸šāšˆāšā¸Ĩāš‰ā¸§", "recent": "ā¸Ĩāšˆā¸˛ā¸Ē⏏⏔", - "recent-albums": "ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸Ĩāšˆā¸˛ā¸Ē⏏⏔", + "recent_albums": "ā¸­ā¸ąā¸Ĩā¸šā¸ąāš‰ā¸Ąā¸Ĩāšˆā¸˛ā¸Ē⏏⏔", "recent_searches": "ā¸ā¸˛ā¸Ŗā¸„āš‰ā¸™ā¸Ģ⏞ā¸Ĩāšˆā¸˛ā¸Ē⏏⏔", "recently_added_page_title": "āš€ā¸žā¸´āšˆā¸Ąā¸Ĩāšˆā¸˛ā¸Ē⏏⏔", "refresh": "⏪ā¸ĩāš€ā¸Ÿā¸Ŗā¸Š", @@ -1603,8 +1660,8 @@ "server_endpoint": "⏛ā¸Ĩ⏞ā¸ĸā¸—ā¸˛ā¸‡āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ", "server_info_box_app_version": "āš€ā¸§ā¸­ā¸ŖāšŒā¸Šā¸ąā¸™āšā¸­ā¸ž", "server_info_box_server_url": "URL āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ", - "server_offline": "Server ā¸­ā¸­ā¸Ÿāš„ā¸Ĩā¸™āšŒ", - "server_online": "Server ā¸­ā¸­ā¸™āš„ā¸Ĩā¸™āšŒ", + "server_offline": "āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒā¸­ā¸­ā¸Ÿāš„ā¸Ĩā¸™āšŒ", + "server_online": "āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒā¸­ā¸­ā¸™āš„ā¸Ĩā¸™āšŒ", "server_privacy": "ā¸„ā¸§ā¸˛ā¸Ąāš€ā¸›āš‡ā¸™ā¸Ēāšˆā¸§ā¸™ā¸•ā¸ąā¸§āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ", "server_stats": "ā¸Ēā¸–ā¸´ā¸•ā¸´āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ", "server_version": "āš€ā¸§ā¸­ā¸ŖāšŒā¸Šā¸ąā¸™ā¸‚ā¸­ā¸‡āš€ā¸‹ā¸´ā¸ŖāšŒā¸Ÿāš€ā¸§ā¸­ā¸ŖāšŒ", @@ -1631,7 +1688,7 @@ "setting_notifications_single_progress_subtitle": "ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩā¸„ā¸§ā¸˛ā¸Ąā¸„ā¸ˇā¸šā¸Ģā¸™āš‰ā¸˛ā¸ā¸˛ā¸Ŗā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”āš‚ā¸”ā¸ĸā¸Ĩā¸°āš€ā¸­ā¸ĩā¸ĸā¸”ā¸•āšˆā¸­ā¸—ā¸Ŗā¸ąā¸žā¸ĸ⏞⏁⏪", "setting_notifications_single_progress_title": "āšā¸Ē⏔⏇⏪⏞ā¸ĸā¸Ĩā¸°āš€ā¸­ā¸ĩā¸ĸ⏔ā¸Ē⏖⏞⏙⏰⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšƒā¸™āš€ā¸šā¸ˇāš‰ā¸­ā¸‡ā¸Ģā¸Ĩā¸ąā¸‡", "setting_notifications_subtitle": "ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛ā¸ā¸˛ā¸Ŗāšā¸ˆāš‰ā¸‡āš€ā¸•ā¸ˇā¸­ā¸™", - "setting_notifications_total_progress_subtitle": "ā¸„ā¸§ā¸˛ā¸Ąā¸„ā¸ˇā¸šā¸Ģā¸™āš‰ā¸˛ā¸ā¸˛ā¸Ŗā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”āš‚ā¸”ā¸ĸā¸Ŗā¸§ā¸Ą (āš€ā¸Ēā¸Ŗāš‡ā¸ˆā¸Ēā¸´āš‰ā¸™/ā¸—ā¸Ŗā¸ąā¸žā¸ĸā¸˛ā¸ā¸Ŗā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”)", + "setting_notifications_total_progress_subtitle": "ā¸„ā¸§ā¸˛ā¸Ąā¸„ā¸ˇā¸šā¸Ģā¸™āš‰ā¸˛ā¸ā¸˛ā¸Ŗā¸­ā¸ąā¸›āš‚ā¸Ģā¸Ĩā¸”āš‚ā¸”ā¸ĸā¸Ŗā¸§ā¸Ą (āš€ā¸Ēā¸Ŗāš‡ā¸ˆā¸Ēā¸´āš‰ā¸™/ā¸Ēā¸ˇāšˆā¸­ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”)", "setting_notifications_total_progress_title": "āšā¸Ē⏔⏇ā¸Ē⏖⏞⏙⏰⏁⏞⏪ā¸Ēā¸ŗā¸Ŗā¸­ā¸‡ā¸‚āš‰ā¸­ā¸Ąā¸šā¸Ĩāšƒā¸™āš€ā¸šā¸ˇāš‰ā¸­ā¸‡ā¸Ģā¸Ĩā¸ąā¸‡ā¸—ā¸ąāš‰ā¸‡ā¸Ģā¸Ąā¸”", "setting_video_viewer_looping_title": "⏧⏙ā¸Ĩā¸šā¸›", "settings": "ā¸•ā¸ąāš‰ā¸‡ā¸„āšˆā¸˛", diff --git a/i18n/tr.json b/i18n/tr.json index 20a4f3c04c..1331621ad9 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -115,13 +115,13 @@ "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_title": "KÃŧçÃŧk Fotoğraf AyarlarÄą", "import_config_from_json_description": "JSON yapÄąlandÄąrma dosyasÄą yÃŧkleyerek sistem yapÄąlandÄąrmasÄąnÄą içe aktar", - "job_concurrency": "{job} eş zamanlÄąlÄąk", + "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_not_concurrency_safe": "Bu işlem eşzamanlÄąlÄąk aÃ§ÄąsÄąndan gÃŧvenli değil.", "job_settings": "GÃļrev AyarlarÄą", "job_settings_description": "AynÄą anda çalÄąÅŸacak gÃļrevleri yÃļnet", "jobs_delayed": "{jobCount, plural, other {# gecikmeli}}", - "jobs_failed": "{jobCount, plural, other {# BaşarÄąsÄąz}}", + "jobs_failed": "{jobCount, plural, other {# başarÄąsÄąz}}", "jobs_over_time": "Zaman içinde işler", "library_created": "Oluşturulan kÃŧtÃŧphane : {library}", "library_deleted": "KÃŧtÃŧphane silindi", @@ -140,7 +140,7 @@ "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ÃŧğÃŧ etkinleştir", - "logging_level_description": "Etkinleştirildiğinde hangi gÃŧnlÃŧk seviyesi kullanÄąlÄąr.", + "logging_level_description": "Etkinleştirildiğinde, hangi gÃŧnlÃŧk dÃŧzeyinin kullanÄąlacağı.", "logging_settings": "GÃŧnlÃŧk Tutma", "machine_learning_availability_checks": "KullanÄąlabilirlik kontrolleri", "machine_learning_availability_checks_description": "KullanÄąlabilir makine Ãļğrenimi sunucularÄąnÄą otomatik olarak algÄąlayÄąn ve tercih edin", @@ -163,23 +163,23 @@ "machine_learning_facial_recognition_model_description": "Modeller, azalan boyut sÄąrasÄąna gÃļre listelenmiştir. Daha bÃŧyÃŧk modeller daha yavaştÄąr ve daha fazla bellek kullanÄąr, ancak daha iyi sonuçlar Ãŧretir. Bir modeli değiştirdikten sonra tÃŧm gÃļrÃŧntÃŧler için yÃŧz algÄąlama işini yeniden çalÄąÅŸtÄąrmanÄąz gerektiğini unutmayÄąn.", "machine_learning_facial_recognition_setting": "YÃŧz tanÄąmayÄą etkinleştir", "machine_learning_facial_recognition_setting_description": "Devre dÄąÅŸÄą bÄąrakÄąldığında fotoğraflar yÃŧz tanÄąma için işlenmeyecek ve Keşfet sayfasÄąndaki Kişiler sekmesini doldurmayacak.", - "machine_learning_max_detection_distance": "Maksimum tespit uzaklığı", + "machine_learning_max_detection_distance": "Maksimum algÄąlama mesafesi", "machine_learning_max_detection_distance_description": "Resimleri birbirinin çifti saymak için hesap edilecek azami benzerlik ÃļlçÃŧsÃŧ, 0.001-0.1 aralığında. Daha yÃŧksek değer daha hassas olup daha fazla çift tespit eder ancak çift olmayan resimleri birbirinin çifti sayabilir.", - "machine_learning_max_recognition_distance": "Maksimum tanÄąma uzaklığı", + "machine_learning_max_recognition_distance": "Maksimum tanÄąma mesafesi", "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 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": "TanÄąnan minimum yÃŧz sayÄąsÄą", "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_ocr": "OCR", "machine_learning_ocr_description": "Resimlerdeki metni tanÄąmak için makine Ãļğrenimini kullan", "machine_learning_ocr_enabled": "OCR'yi etkinleştir", "machine_learning_ocr_enabled_description": "Devre dÄąÅŸÄą bÄąrakÄąlÄąrsa, resimler metin tanÄąma işleminden geçmeyecektir.", - "machine_learning_ocr_max_resolution": "En yÃŧksek çÃļzÃŧnÃŧrlÃŧk", + "machine_learning_ocr_max_resolution": "Maksimum çÃļzÃŧnÃŧrlÃŧk", "machine_learning_ocr_max_resolution_description": "Bu çÃļzÃŧnÃŧrlÃŧğÃŧn Ãŧzerindeki Ãļnizlemeler, en-boy oranÄą korunarak yeniden boyutlandÄąrÄąlacaktÄąr. Daha yÃŧksek değerler daha doğru sonuç verir, ancak işlemesi daha uzun sÃŧrer ve daha fazla bellek kullanÄąr.", - "machine_learning_ocr_min_detection_score": "En dÃŧşÃŧk tespit puanÄą", + "machine_learning_ocr_min_detection_score": "Minimum tespit puanÄą", "machine_learning_ocr_min_detection_score_description": "Metnin tespit edilmesi için minimum gÃŧven puanÄą 0-1 arasÄąndadÄąr. DÃŧşÃŧk değerler daha fazla metin tespit eder, ancak yanlÄąÅŸ pozitif sonuçlara yol açabilir.", - "machine_learning_ocr_min_recognition_score": "Minimum tespit puanÄą", + "machine_learning_ocr_min_recognition_score": "Minimum tanÄąma puanÄą", "machine_learning_ocr_min_score_recognition_description": "AlgÄąlanan metnin tanÄąnmasÄą için minimum gÃŧven puanÄą 0-1 arasÄąndadÄąr. Daha dÃŧşÃŧk değerler daha fazla metni tanÄąr, ancak yanlÄąÅŸ pozitif sonuçlara neden olabilir.", "machine_learning_ocr_model": "OCR modeli", "machine_learning_ocr_model_description": "Sunucu modelleri mobil modellerden daha doğrudur, ancak işlenmesi daha uzun sÃŧrer ve daha fazla bellek kullanÄąr.", @@ -243,7 +243,7 @@ "nightly_tasks_settings_description": "Gece gÃļrevlerini yÃļnet", "nightly_tasks_start_time_setting": "BaşlangÄąÃ§ saati", "nightly_tasks_start_time_setting_description": "Sunucunun gece gÃļrevlerini çalÄąÅŸtÄąrmaya başladığı saat", - "nightly_tasks_sync_quota_usage_setting": "Kota kullanÄąmÄąnÄą eşzamanla", + "nightly_tasks_sync_quota_usage_setting": "Kota kullanÄąmÄąnÄą senkronize et", "nightly_tasks_sync_quota_usage_setting_description": "Mevcut kullanÄąma gÃļre kullanÄącÄą depolama kotasÄąnÄą gÃŧncelle", "no_paths_added": "Yol eklenmedi", "no_pattern_added": "Desen eklenmedi", @@ -321,7 +321,7 @@ "server_welcome_message_description": "Giriş sayfasÄąnda gÃļsterilen mesaj.", "settings_page_description": "YÃļnetici ayarlar sayfasÄą", "sidecar_job": "Ek dosya ile taÅŸÄąnan metadata", - "sidecar_job_description": "Dosya sisteminden yan araç meta verilerini keşfedin veya eşzamanlayÄąn", + "sidecar_job_description": "Dosya sisteminden sidecar meta verilerini keşfedin veya senkronize edin", "slideshow_duration_description": "Her fotoğrafÄąn kaç saniye gÃļrÃŧntÃŧleneceği", "smart_search_job_description": "AkÄąllÄą aramayÄą desteklemek için tÃŧm Ãļğelerde makine Ãļğrenmesini çalÄąÅŸtÄąrÄąn", "storage_template_date_time_description": "Öğenin oluşturulma zaman damgasÄą, tarih ve saat bilgisi için kullanÄąlÄąr", @@ -359,7 +359,7 @@ "transcoding_acceleration_api": "HÄązlandÄąrma API", "transcoding_acceleration_api_description": "Video formatÄą çevriminde kullanÄąlacak API. Bu ayara 'mÃŧmkÃŧn olduğunca' uyulmaktadÄąr; seçilen API'da sorun Ã§Äąkarsa yazÄąlÄąm tabanlÄą çevirime dÃļnÃŧlÃŧr. VP9 donanÄąmÄąnÄąza bağlÄą olarak çalÄąÅŸmayabilir.", "transcoding_acceleration_nvenc": "NVENC (NVIDIA GPU gerektirir)", - "transcoding_acceleration_qsv": "HÄązlÄą Eşzamanlama (7. nesil veya daha yeni bir Intel CPU gerektirir)", + "transcoding_acceleration_qsv": "HÄązlÄą Senkronizasyon (7. nesil Intel işlemci veya Ãŧzeri gerektirir)", "transcoding_acceleration_rkmpp": "RKMPP (Sadece Rockchip SOC'ler)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Kabul edilen ses kodekleri", @@ -386,7 +386,7 @@ "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_max_b_frames": "Maksimum B-kareler", "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": "Maksimum bit hÄązÄą", "transcoding_max_bitrate_description": "Maksimum bit hÄązÄą ayarlamak, kaliteyi az bir maliyetle dÃŧşÃŧrerek dosya boyutlarÄąnÄą daha ÃļngÃļrÃŧlebilir hale getirebilir. 720p çÃļzÃŧnÃŧrlÃŧkte, tipik değerler VP9 veya HEVC için 2600 kbit/s, H.264 için ise 4500 kbit/s’dir. 0 olarak ayarlanÄąrsa devre dÄąÅŸÄą bÄąrakÄąlÄąr. Birim belirtilmediğinde, k (kbit/s için) varsayÄąlÄąr; bu nedenle 5000, 5000k ve 5M (Mbit/s için) eşdeğerdir.", "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.", @@ -466,7 +466,7 @@ "advanced_settings_self_signed_ssl_subtitle": "Sunucu uç noktasÄą için SSL sertifika doğrulamasÄąnÄą atlar. Kendinden imzalÄą sertifikalar için gereklidir.", "advanced_settings_self_signed_ssl_title": "Kendinden imzalÄą SSL sertifikalarÄąna izin ver [DENEYSEL]", "advanced_settings_sync_remote_deletions_subtitle": "Web Ãŧzerinde işlem yapÄąldığında, bu aygÄąttaki Ãļğeyi otomatik olarak sil veya geri yÃŧkle", - "advanced_settings_sync_remote_deletions_title": "Uzaktan silmeleri eşzamanla [DENEYSEL]", + "advanced_settings_sync_remote_deletions_title": "Uzaktan silme işlemlerini senkronize et [DENEYSEL]", "advanced_settings_tile_subtitle": "Gelişmiş kullanÄącÄą ayarlarÄą", "advanced_settings_troubleshooting_subtitle": "Sorun giderme için ek Ãļzellikleri etkinleştirin", "advanced_settings_troubleshooting_title": "Sorun Giderme", @@ -626,7 +626,7 @@ "backup_album_selection_page_select_albums": "AlbÃŧmleri seç", "backup_album_selection_page_selection_info": "Seçim Bilgileri", "backup_album_selection_page_total_assets": "Toplam eşsiz Ãļğeler", - "backup_albums_sync": "AlbÃŧm Senkronizasyonunu Yedekle", + "backup_albums_sync": "AlbÃŧm Yedekleme Senkronizasyonu", "backup_all": "TÃŧmÃŧ", "backup_background_service_backup_failed_message": "Yedekleme başarÄąsÄąz. Tekrar deneniyorâ€Ļ", "backup_background_service_complete_notification": "Öğe yedekleme tamamlandÄą", @@ -1330,9 +1330,9 @@ "invite_people": "Kişileri Davet Et", "invite_to_album": "AlbÃŧme davet et", "ios_debug_info_fetch_ran_at": "Veri çekme {dateTime} tarihinde çalÄąÅŸtÄąrÄąldÄą", - "ios_debug_info_last_sync_at": "Son eşzamanlama {dateTime}", + "ios_debug_info_last_sync_at": "Son senkronizasyon {dateTime}", "ios_debug_info_no_processes_queued": "Hiçbir arka plan işlemi kuyruğa alÄąnmadÄą", - "ios_debug_info_no_sync_yet": "HenÃŧz arka plan eşzamanlama gÃļrevi çalÄąÅŸtÄąrÄąlmadÄą", + "ios_debug_info_no_sync_yet": "HenÃŧz hiçbir arka plan senkronizasyon gÃļrevi çalÄąÅŸtÄąrÄąlmadÄą", "ios_debug_info_processes_queued": "{count, plural, one {{count} arka plan işlemi kuyruğa alÄąndÄą} other {{count} arka plan işlemi kuyruğa alÄąndÄą}}", "ios_debug_info_processing_ran_at": "İşleme {dateTime} tarihinde çalÄąÅŸtÄąrÄąldÄą", "items_count": "{count, plural, one {# Öğe} other {# Öğe}}", @@ -1613,7 +1613,6 @@ "not_available": "YOK", "not_in_any_album": "Hiçbir albÃŧmde değil", "not_selected": "Seçilmedi", - "note_apply_storage_label_to_previously_uploaded assets": "Not: Daha Ãļnce yÃŧklenen Ãļğeler için bir depolama yolu etiketi uygulamak Ãŧzere şunu başlatÄąn", "notes": "Notlar", "nothing_here_yet": "Burada henÃŧz bir şey yok", "notification_permission_dialog_content": "Bildirimleri etkinleştirmek için cihaz ayarlarÄąna gidin ve izin verin.", @@ -1649,7 +1648,7 @@ "options": "Seçenekler", "or": "veya", "organize_into_albums": "AlbÃŧmler halinde dÃŧzenle", - "organize_into_albums_description": "Mevcut eşzamanlama ayarlarÄąnÄą kullanarak mevcut fotoğraflarÄą albÃŧmlere ekleyin", + "organize_into_albums_description": "Mevcut fotoğraflarÄą geçerli senkronizasyon ayarlarÄąnÄą kullanarak albÃŧmlere yerleştirin", "organize_your_library": "KÃŧtÃŧphanenizi dÃŧzenleyin", "original": "orijinal", "other": "Diğer", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# Ãļğe} other {# Ãļğeler}} yeni bir kişiye atandÄą", "reassing_hint": "Seçili Ãļğeleri mevcut bir kişiye atayÄąn", "recent": "Son", - "recent-albums": "Son kaydedilen albÃŧmler", + "recent_albums": "Son kaydedilen albÃŧmler", "recent_searches": "Son aramalar", "recently_added": "Son eklenenler", "recently_added_page_title": "Son Eklenenler", @@ -1876,7 +1875,7 @@ "reset_pin_code_success": "PIN kodu başarÄąyla sÄąfÄąrlandÄą", "reset_pin_code_with_password": "PIN kodunuzu her zaman şifrenizle sÄąfÄąrlayabilirsiniz", "reset_sqlite": "SQLite VeritabanÄąnÄą SÄąfÄąrla", - "reset_sqlite_confirmation": "SQLite veritabanÄąnÄą sÄąfÄąrlamak istediğinizden emin misiniz? Verileri yeniden eşzamanlamak için oturumu kapatÄąp tekrar oturum açmanÄąz gerekecektir", + "reset_sqlite_confirmation": "SQLite veritabanÄąnÄą sÄąfÄąrlamak istediğinizden emin misiniz? Verileri yeniden senkronize etmek için oturumu kapatÄąp tekrar giriş yapmanÄąz gerekecek", "reset_sqlite_success": "SQLite veritabanÄąnÄą başarÄąyla sÄąfÄąrladÄąnÄąz", "reset_to_default": "VarsayÄąlana sÄąfÄąrla", "resolution": "ÇÃļzÃŧnÃŧrlÃŧk", @@ -2185,13 +2184,13 @@ "support_and_feedback": "Destek & Geri Bildirim", "support_third_party_description": "Immich kurulumu ÃŧçÃŧncÃŧ bir tarafça yapÄąldÄą. YaşadığınÄąz sorunlar bu paketle ilgili olabilir. LÃŧtfen Ãļncelikli olarak aşağıdaki bağlantÄąlarÄą kullanarak bu sağlayÄącÄąyla iletişime geçin.", "swap_merge_direction": "Birleştirme yÃļnÃŧnÃŧ değiştir", - "sync": "Eşzamanla", - "sync_albums": "AlbÃŧmleri eşzamanla", - "sync_albums_manual_subtitle": "YÃŧklenmiş fotoğraf ve videolarÄą yedekleme için seçili albÃŧmler ile eşzamanlayÄąn", - "sync_local": "Yerel Eşzamanlama", - "sync_remote": "Uzaktan Eşzamanlama", - "sync_status": "Eşzamanlama Durumu", - "sync_status_subtitle": "Eşzamanlama sistemini gÃļrÃŧntÃŧleyin ve yÃļnetin", + "sync": "Senkronizasyon", + "sync_albums": "AlbÃŧmleri senkronize et", + "sync_albums_manual_subtitle": "YÃŧklenen tÃŧm videolarÄą ve fotoğraflarÄą seçilen yedekleme albÃŧmlerine senkronize edin", + "sync_local": "Yerel Senkronizasyon", + "sync_remote": "Uzaktan Senkronizasyon", + "sync_status": "Senkronizasyon Durumu", + "sync_status_subtitle": "Senkronizasyon sistemini gÃļrÃŧntÃŧleyin ve yÃļnetin", "sync_upload_album_setting_subtitle": "FotoğraflarÄąnÄązÄą ve videolarÄąnÄązÄą oluşturun ve Immich'te seçtiğiniz albÃŧmlere yÃŧkleyin", "tag": "Etiket", "tag_assets": "Öğeleri etiketle", diff --git a/i18n/uk.json b/i18n/uk.json index 43f18bfcea..0609edf28c 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -1613,7 +1613,6 @@ "not_available": "НĐĩĐŧĐ°Ņ” даĐŊĐ¸Ņ…", "not_in_any_album": "ĐŖ ĐļОдĐŊĐžĐŧ҃ аĐģŅŒĐąĐžĐŧŅ–", "not_selected": "НĐĩ Đ˛Đ¸ĐąŅ€Đ°ĐŊĐž", - "note_apply_storage_label_to_previously_uploaded assets": "ĐŸŅ€Đ¸ĐŧŅ–Ņ‚Đēа: ЊОй ĐˇĐ°ŅŅ‚ĐžŅŅƒĐ˛Đ°Ņ‚Đ¸ ĐŧŅ–Ņ‚Đē҃ ŅŅ…ĐžĐ˛Đ¸Ņ‰Đ° Đ´Đž Ņ€Đ°ĐŊŅ–ŅˆĐĩ виваĐŊŅ‚Đ°ĐļĐĩĐŊĐ¸Ņ… Ņ„Đ°ĐšĐģŅ–Đ˛, виĐēĐžĐŊĐ°ĐšŅ‚Đĩ ĐēĐžĐŧаĐŊĐ´Ņƒ", "notes": "ĐĐžŅ‚Đ°Ņ‚Đēи", "nothing_here_yet": "ĐĸŅƒŅ‚ ҉Đĩ ĐŊŅ–Ņ‡ĐžĐŗĐž ĐŊĐĩĐŧĐ°Ņ”", "notification_permission_dialog_content": "ЊОй ŅƒĐ˛Ņ–ĐŧĐēĐŊŅƒŅ‚Đ¸ ҁĐŋĐžĐ˛Ņ–Ņ‰ĐĩĐŊĐŊŅ, ĐŋĐĩŅ€ĐĩĐšĐ´Ņ–Ņ‚ŅŒ Đ´Đž НаĐģĐ°ŅˆŅ‚ŅƒĐ˛Đ°ĐŊҌ Ņ– ĐŊĐ°Đ´Đ°ĐšŅ‚Đĩ Đ´ĐžĐˇĐ˛Ņ–Đģ.", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "ПĐĩŅ€ĐĩĐŋŅ€Đ¸ĐˇĐŊĐ°Ņ‡ĐĩĐŊĐž {count, plural, one {# Ņ„Đ°ĐšĐģ} few {# Ņ„Đ°ĐšĐģи} many {# Ņ„Đ°ĐšĐģŅ–Đ˛} other {# Ņ„Đ°ĐšĐģŅ–Đ˛}} ĐŊĐžĐ˛Ņ–Đš ĐžŅĐžĐąŅ–", "reassing_hint": "ĐŸŅ€Đ¸ĐˇĐŊĐ°Ņ‡Đ¸Ņ‚Đ¸ ĐžĐąŅ€Đ°ĐŊŅ– Ņ„Đ°ĐšĐģи ҖҁĐŊŅƒŅŽŅ‡Ņ–Đš ĐžŅĐžĐąŅ–", "recent": "НĐĩŅ‰ĐžĐ´Đ°Đ˛ĐŊĐž", - "recent-albums": "ĐžŅŅ‚Đ°ĐŊĐŊŅ– аĐģŅŒĐąĐžĐŧи", + "recent_albums": "ĐžŅŅ‚Đ°ĐŊĐŊŅ– аĐģŅŒĐąĐžĐŧи", "recent_searches": "НĐĩŅ‰ĐžĐ´Đ°Đ˛ĐŊŅ– ĐŋĐžŅˆŅƒĐēĐžĐ˛Ņ– СаĐŋĐ¸Ņ‚Đ¸", "recently_added": "НĐĩŅ‰ĐžĐ´Đ°Đ˛ĐŊĐž дОдаĐŊŅ–", "recently_added_page_title": "НĐĩŅ‰ĐžĐ´Đ°Đ˛ĐŊŅ–", diff --git a/i18n/vi.json b/i18n/vi.json index fa9d0d0c17..0ba340ad2c 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -1516,7 +1516,6 @@ "not_available": "Thiáēŋu", "not_in_any_album": "Không thuáģ™c album nào", "not_selected": "Không đưáģŖc cháģn", - "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", "notes": "Lưu ÃŊ", "nothing_here_yet": "Chưa cÃŗ náģ™i dung nào", "notification_permission_dialog_content": "Đáģƒ báē­t thông bÃĄo, chuyáģƒn táģ›i Cài đáēˇt và cháģn cho phÊp.", @@ -1717,7 +1716,7 @@ "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-albums": "Album gáē§n đÃĸy", + "recent_albums": "Album gáē§n đÃĸy", "recent_searches": "TÃŦm kiáēŋm gáē§n đÃĸy", "recently_added": "ThÃĒm gáē§n đÃĸy", "recently_added_page_title": "Máģ›i thÃĒm gáē§n đÃĸy", diff --git a/i18n/yue_Hant.json b/i18n/yue_Hant.json index bb5ea96488..372816da2a 100644 --- a/i18n/yue_Hant.json +++ b/i18n/yue_Hant.json @@ -41,6 +41,7 @@ "add_to_bottom_bar": "åŠ č‡ŗ", "add_to_shared_album": "åŠ č‡ŗå…ąäēĢᛏį°ŋ", "add_url": "加įļ˛å€", + "add_workflow_step": "åĸžåŠ åˇĨäŊœæ­Ĩ驟", "added_to_favorites": "åˇ˛åŠ č‡ŗæœ€æ„›", "added_to_favorites_count": "厞加{count, number} å€‹é …į›Žč‡ŗæœ€æ„›", "admin": { diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_Hans.json similarity index 98% rename from i18n/zh_SIMPLIFIED.json rename to i18n/zh_Hans.json index d308cd2318..2e7960bffd 100644 --- a/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_Hans.json @@ -685,35 +685,35 @@ "backup_manual_title": "上äŧ įŠļ态", "backup_options": "备äģŊ选项", "backup_options_page_title": "备äģŊ选项", - "backup_setting_subtitle": "įŽĄį†åŽå°å’Œå‰å°ä¸Šäŧ čŽžįŊŽ", + "backup_setting_subtitle": "įŽĄį†åŽå°ä¸Žå‰å°ä¸Šäŧ čŽžįŊŽ", "backup_settings_subtitle": "įŽĄį†ä¸Šäŧ čŽžįŊŽ", - "backup_upload_details_page_more_details": "į‚šå‡ģäē†č§Ŗč¯Ļ情", + "backup_upload_details_page_more_details": "į‚šå‡ģæŸĨįœ‹č¯Ļ情", "backward": "后退", - "biometric_auth_enabled": "į”Ÿį‰Šč¯†åˆĢčēĢäģŊéĒŒč¯åˇ˛å¯į”¨", - "biometric_locked_out": "您čĸĢé”åŽšåœ¨į”Ÿį‰Šč¯†åˆĢčēĢäģŊéĒŒč¯äš‹å¤–", - "biometric_no_options": "æ˛Ąæœ‰å¯į”¨įš„į”Ÿį‰Šč¯†åˆĢ选项", - "biometric_not_available": "į”Ÿį‰Šč¯†åˆĢčēĢäģŊéĒŒč¯åœ¨æ­¤čŽžå¤‡ä¸Šä¸å¯į”¨", + "biometric_auth_enabled": "į”Ÿį‰Šč¯†åˆĢčŽ¤č¯åˇ˛å¯į”¨", + "biometric_locked_out": "æ‚¨åˇ˛čĸĢ锁厚īŧŒæ— æŗ•äŊŋį”¨į”Ÿį‰Šč¯†åˆĢčŽ¤č¯", + "biometric_no_options": "æ— å¯į”¨įš„į”Ÿį‰Šč¯†åˆĢ选项", + "biometric_not_available": "æœŦčŽžå¤‡ä¸æ”¯æŒį”Ÿį‰Šč¯†åˆĢčŽ¤č¯", "birthdate_saved": "å‡ēį”Ÿæ—Ĩ期äŋå­˜æˆåŠŸ", - "birthdate_set_description": "å‡ēį”Ÿæ—ĨæœŸį”¨äēŽčŽĄįŽ—į…§į‰‡ä¸­č¯Ĩäēēį‰Šåœ¨æ‹į…§æ—ļįš„åš´éž„ã€‚", - "blurred_background": "čƒŒæ™¯æ¨ĄįŗŠ", - "bugs_and_feature_requests": "Bug 与功čƒŊč¯ˇæą‚", + "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", "cache_settings_clear_cache_button": "清除įŧ“å­˜", - "cache_settings_clear_cache_button_title": "清除åē”ᔍįŧ“å­˜ã€‚åœ¨é‡æ–°į”Ÿæˆįŧ“存䚋前īŧŒå°†æ˜žč‘—åŊąå“åē”į”¨įš„æ€§čƒŊ。", + "cache_settings_clear_cache_button_title": "æ¸…į†åē”ᔍįŧ“存。在įŧ“存重åģ翜Ÿé—´īŧŒåē”į”¨įš„čŋčĄŒé€ŸåēĻäŧšæ˜Žæ˜žå˜æ…ĸ。", "cache_settings_duplicated_assets_clear_button": "清除", - "cache_settings_duplicated_assets_subtitle": "åē”ᔍፋåēåŋŊį•Ĩįš„į…§į‰‡å’Œč§†éĸ‘", + "cache_settings_duplicated_assets_subtitle": "åŋŊį•Ĩåˆ—čĄ¨ä¸­įš„åĒ’äŊ“æ–‡äģļ", "cache_settings_duplicated_assets_title": "重复čĩ„äē§īŧˆ{count}īŧ‰", - "cache_settings_statistics_album": "čĩ„äē§åē“įŧŠį•Ĩ回", - "cache_settings_statistics_full": "厌整回像", + "cache_settings_statistics_album": "回åē“įŧŠį•Ĩ回", + "cache_settings_statistics_full": "原回", "cache_settings_statistics_shared": "å…ąäēĢį›¸å†ŒįŧŠį•Ĩ回", "cache_settings_statistics_thumbnail": "įŧŠį•Ĩ回", - "cache_settings_statistics_title": "įŧ“å­˜äŊŋį”¨æƒ…å†ĩ", - "cache_settings_subtitle": "控åˆļ Immich app įš„įŧ“å­˜čĄŒä¸ē", + "cache_settings_statistics_title": "įŧ“å­˜å į”¨æƒ…å†ĩ", + "cache_settings_subtitle": "įŽĄį† Immich 手æœēįĢ¯įš„įŧ“å­˜", "cache_settings_tile_subtitle": "莞įŊŽæœŦåœ°å­˜å‚¨čĄŒä¸ē", "cache_settings_tile_title": "æœŦ地存储", "cache_settings_title": "įŧ“å­˜čŽžįŊŽ", @@ -1613,7 +1613,6 @@ "not_available": "ä¸é€‚į”¨", "not_in_any_album": "不在äģģäŊ•į›¸å†Œä¸­", "not_selected": "æœĒ选拊", - "note_apply_storage_label_to_previously_uploaded assets": "提į¤ēīŧščĻå°†å­˜å‚¨æ ‡į­žåē”ᔍäēŽäš‹å‰ä¸Šäŧ įš„éĄšį›ŽīŧŒč¯ˇčŋčĄŒæ­¤", "notes": "提į¤ē", "nothing_here_yet": "čŋ™é‡Œäģ€äšˆéƒŊæ˛Ąæœ‰", "notification_permission_dialog_content": "čĻå¯į”¨é€šįŸĨīŧŒč¯ˇčŊŦåˆ°â€œčŽžįŊŽâ€īŧŒåšļé€‰æ‹Šâ€œå…čŽ¸â€ã€‚", @@ -1815,7 +1814,7 @@ "reassigned_assets_to_new_person": "重新指洞{count, plural, one {#ä¸ĒéĄšį›Ž} other {#ä¸ĒéĄšį›Ž}}åˆ°æ–°įš„äēēį‰Š", "reassing_hint": "æŒ‡æ´žé€‰æ‹Šįš„éĄšį›Žåˆ°åˇ˛å­˜åœ¨įš„äēēį‰Š", "recent": "最čŋ‘", - "recent-albums": "最čŋ‘įš„į›¸å†Œ", + "recent_albums": "最čŋ‘įš„į›¸å†Œ", "recent_searches": "最čŋ‘搜į´ĸ", "recently_added": "čŋ‘期æˇģ加", "recently_added_page_title": "最čŋ‘æˇģ加", diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index c1b1a3d24c..60dae6ed22 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -3,134 +3,134 @@ "account": "å¸ŗč™Ÿ", "account_settings": "å¸ŗč™Ÿč¨­åŽš", "acknowledge": "äē†č§Ŗ", - "action": "操äŊœ", + "action": "動äŊœ", "action_common_update": "更新", - "action_description": "å°į¯Šé¸åžŒįš„čŗ‡į”ĸåŸˇčĄŒįš„ä¸€įĩ„操äŊœ", - "actions": "é€˛čĄŒå‹•äŊœ", + "action_description": "å°į¯Šé¸åžŒįš„é …į›ŽåŸˇčĄŒä¸€įĩ„å‹•äŊœ", + "actions": "動äŊœ", "active": "處ᐆ䏭", "active_count": "處ᐆ䏭īŧš{count}", "activity": "動態", - "activity_changed": "å‹•æ…‹åˇ˛{enabled, select, true {開啟} other {關閉}}", + "activity_changed": "å‹•æ…‹åˇ˛{enabled, select, true {å•Ÿį”¨} other {åœį”¨}}", "add": "加å…Ĩ", - "add_a_description": "新åĸžæčŋ°", + "add_a_description": "新åĸžčĒĒæ˜Ž", "add_a_location": "新åĸžåœ°éģž", - "add_a_name": "加å…Ĩ姓名", + "add_a_name": "新åĸžå§“名", "add_a_title": "新åĸžæ¨™éĄŒ", - "add_action": "æˇģ加動äŊœ", - "add_action_description": "按一下äģĨæˇģ加čĻåŸˇčĄŒįš„æ“äŊœ", - "add_assets": "æˇģåŠ čŗ‡æē", + "add_action": "新åĸžå‹•äŊœ", + "add_action_description": "按一下äģĨ新åĸžčĻåŸˇčĄŒįš„å‹•äŊœ", + "add_assets": "新åĸžé …į›Ž", "add_birthday": "新åĸžį”Ÿæ—Ĩ", "add_endpoint": "新åĸžį̝éģž", - "add_exclusion_pattern": "加å…Ĩį¯Šé¸æĸäģļ", - "add_filter": "æˇģåŠ į¯Šé¸å™¨", - "add_filter_description": "按一下äģĨæˇģåŠ į¯Šé¸æĸäģļ", + "add_exclusion_pattern": "新åĸžæŽ’é™¤æ¨Ąåŧ", + "add_filter": "新åĸžį¯Šé¸å™¨", + "add_filter_description": "按一下äģĨ新åĸžį¯Šé¸æĸäģļ", "add_location": "新åĸžåœ°éģž", "add_more_users": "新åĸžå…ļäģ–äŊŋᔍ者", - "add_partner": "新åĸžčĻĒæœ‹åĨŊ友", + "add_partner": "新åĸžčĻĒ友", "add_path": "新åĸžčˇ¯åž‘", - "add_photos": "加å…Ĩᅧቇ", + "add_photos": "加å…Ĩᛏቇ", "add_tag": "加å…Ĩæ¨™įą¤", - "add_to": "加å…Ĩ到â€Ļ", + "add_to": "加å…Ĩ臺â€Ļ", "add_to_album": "加å…Ĩåˆ°į›¸į°ŋ", - "add_to_album_bottom_sheet_added": "新åĸžåˆ° {album}", + "add_to_album_bottom_sheet_added": "åˇ˛æ–°åĸžč‡ŗ {album}", "add_to_album_bottom_sheet_already_exists": "厞圍 {album} 中", - "add_to_album_bottom_sheet_some_local_assets": "į„Ąæŗ•å°‡æŸä盿œŦæŠŸčŗ‡į”ĸ新åĸžåˆ°į›¸į°ŋ", - "add_to_album_toggle": "é¸æ“‡į›¸į°ŋ{album}", + "add_to_album_bottom_sheet_some_local_assets": "į„Ąæŗ•å°‡éƒ¨åˆ†æœŦæŠŸé …į›Žæ–°åĸžč‡ŗį›¸į°ŋ", + "add_to_album_toggle": "é¸å–į›¸į°ŋ {album}", "add_to_albums": "加å…Ĩᛏį°ŋ", "add_to_albums_count": "將 ({count}) å€‹é …į›ŽåŠ å…Ĩᛏį°ŋ", "add_to_bottom_bar": "新åĸžåˆ°", - "add_to_shared_album": "åŠ åˆ°å…ąäēĢᛏį°ŋ", + "add_to_shared_album": "新åĸžč‡ŗå…ąäēĢᛏį°ŋ", "add_upload_to_stack": "新åĸžä¸Šå‚ŗåˆ°å †į–Š", "add_url": "新åĸž URL", - "add_workflow_step": "æˇģ加åˇĨäŊœæĩæ­Ĩ驟", + "add_workflow_step": "新åĸžåˇĨäŊœæĩį¨‹æ­Ĩ驟", "added_to_archive": "į§ģč‡ŗå°å­˜", "added_to_favorites": "加å…Ĩæ”ļ藏", - "added_to_favorites_count": "將 {count, number} å€‹é …į›ŽåŠ å…Ĩæ”ļ藏", + "added_to_favorites_count": "厞將 {count, number} å€‹é …į›ŽåŠ å…Ĩæ”ļ藏", "admin": { - "add_exclusion_pattern_description": "新åĸžæŽ’除æĸäģļ。支援äŊŋį”¨ã€Œ*」、「 **」、「?」䞆扞尋įŦĻ合čĻå‰‡įš„å­—ä¸˛ã€‚åĻ‚æžœčρ圍äģģäŊ•名į‚ē「Rawã€įš„į›ŽéŒ„å…§æŽ’é™¤æ‰€æœ‰įŦĻ合æĸäģļįš„æĒ”æĄˆīŧŒčĢ‹äŊŋį”¨ã€Œ**/Raw/**」。åĻ‚æžœčĻæŽ’é™¤æ‰€æœ‰ã€Œ.tif」įĩå°žįš„æĒ”æĄˆīŧŒčĢ‹äŊŋį”¨ã€Œ**/*.tif」。åĻ‚æžœčĻæŽ’é™¤æŸå€‹įĩ•å°čˇ¯åž‘īŧŒčĢ‹äŊŋį”¨ã€Œ/path/to/ignore/**」。", + "add_exclusion_pattern_description": "新åĸžæŽ’é™¤æ¨Ąåŧã€‚支援äŊŋᔍ *、** 與 ? 進行čŦį”¨å­—å…ƒæ¯”å° (Globbing)。č‹ĨčρåŋŊį•ĨäģģäŊ•名į‚ē「Rawã€į›ŽéŒ„ä¸­įš„æ‰€æœ‰æĒ”æĄˆīŧŒčĢ‹äŊŋį”¨ã€Œ**/Raw/**」īŧ›č‹ĨčρåŋŊį•Ĩ所有äģĨ「.tif」įĩå°žįš„æĒ”æĄˆīŧŒčĢ‹äŊŋį”¨ã€Œ**/*.tif」īŧ›č‹ĨčρåŋŊį•Ĩį‰šåŽšįš„įĩ•å°čˇ¯åž‘īŧŒčĢ‹äŊŋį”¨ã€Œ/path/to/ignore/**」。", "admin_user": "įŽĄį†å“Ą", - "asset_offline_description": "此外部åĒ’éĢ”åēĢé …į›Žåˇ˛į„Ąæŗ•åœ¨įŖįĸŸä¸Šæ‰žåˆ°īŧŒä¸Ļ厞į§ģč‡ŗåžƒåœžæĄļ。č‹Ĩ芲æĒ”æĄˆæ˜¯åœ¨åĒ’éĢ”åēĢ內į§ģ動īŧŒčĢ‹åœ¨æ™‚é–“čģ¸ä¸­æĒĸčĻ–æ–°įš„å°æ‡‰é …į›Žã€‚č‹ĨčĻé‚„åŽŸæ­¤é …į›ŽīŧŒčĢ‹įĸēäŋä¸‹æ–šįš„æĒ”æĄˆčˇ¯åž‘å¯äž› Immich 存取īŧŒä¸Ļ重新掃描åĒ’éĢ”åēĢ。", + "asset_offline_description": "此外部åĒ’éĢ”åēĢé …į›Žåˇ˛į„Ąæŗ•åœ¨įŖįĸŸä¸Šæ‰žåˆ°īŧŒä¸Ļ厞į§ģč‡ŗåžƒåœžæĄļ。č‹Ĩ芲æĒ”æĄˆæ˜¯åœ¨åĒ’éĢ”åēĢ內į§ģ動īŧŒčĢ‹åœ¨æ™‚é–“čģ¸ä¸­æŸĨįœ‹æ–°įš„å°æ‡‰é …į›Žã€‚č‹ĨčĻé‚„åŽŸæ­¤é …į›ŽīŧŒčĢ‹įĸēäŋä¸‹æ–šįš„æĒ”æĄˆčˇ¯åž‘å¯äž› Immich 存取īŧŒä¸Ļ重新掃描åĒ’éĢ”åēĢ。", "authentication_settings": "éŠ—č­‰č¨­åŽš", "authentication_settings_description": "įŽĄį†å¯†įĸŧ、OAuth 與å…ļäģ–éŠ—č­‰č¨­åŽš", - "authentication_settings_disable_all": "įĸē厚čĻåœį”¨æ‰€æœ‰į™ģå…Ĩæ–šåŧå—ŽīŧŸé€™æ¨ŖæœƒåŽŒå…¨į„Ąæŗ•į™ģå…Ĩ。", - "authentication_settings_reenable": "åĻ‚éœ€é‡æ–°å•Ÿį”¨īŧŒčĢ‹äŊŋᔍ äŧ翜å™¨æŒ‡äģ¤ ã€‚", + "authentication_settings_disable_all": "您įĸē厚čĻåœį”¨æ‰€æœ‰į™ģå…Ĩæ–šåŧå—ŽīŧŸé€™å°‡å°Žč‡´åŽŒå…¨į„Ąæŗ•į™ģå…Ĩ。", + "authentication_settings_reenable": "åĻ‚éœ€é‡æ–°å•Ÿį”¨īŧŒčĢ‹äŊŋᔍ äŧ翜å™¨æŒ‡äģ¤ã€‚", "background_task_job": "čƒŒæ™¯åˇĨäŊœ", "backup_database": "åģēįĢ‹čŗ‡æ–™åēĢ備äģŊ", "backup_database_enable_description": "å•Ÿį”¨čŗ‡æ–™åēĢ備äģŊ", "backup_keep_last_amount": "äŋį•™å…ˆå‰å‚™äģŊįš„æ•¸é‡", "backup_onboarding_1_description": "åœ¨é›˛įĢ¯æˆ–å…ļäģ–å¯ĻéĢ”äŊįŊŽįš„į•°åœ°å‚™äģŊ副æœŦ。", - "backup_onboarding_2_description": "å„˛å­˜åœ¨ä¸åŒčŖįŊŽä¸Šįš„æœŦ抟副æœŦ。這包æ‹Ŧä¸ģčρæĒ”æĄˆåŠå…ļæœŦ抟備äģŊ。", + "backup_onboarding_2_description": "å„˛å­˜åœ¨ä¸åŒčŖįŊŽä¸Šįš„æœŦ抟副æœŦ。這包åĢä¸ģčρæĒ”æĄˆåŠå…ļæœŦ抟備äģŊ。", "backup_onboarding_3_description": "æ‚¨čŗ‡æ–™įš„į¸Ŋ備äģŊäģŊ數īŧŒåŒ…æ‹Ŧ原始æĒ”æĄˆåœ¨å…§ã€‚é€™åŒ…æ‹Ŧ 1 äģŊį•°åœ°å‚™äģŊ與 2 äģŊæœŦ抟副æœŦ。", - "backup_onboarding_description": "åģēč­°æŽĄį”¨ 3-2-1 備äģŊį­–į•Ĩ 來äŋč­ˇæ‚¨įš„čŗ‡æ–™ã€‚æ‚¨æ‡‰äŋį•™åˇ˛ä¸Šå‚ŗįš„ᅧቇ/åŊąį‰‡å‰¯æœŦīŧŒäģĨ及 Immich čŗ‡æ–™åēĢīŧŒäģĨåģēįĢ‹åŽŒæ•´įš„å‚™äģŊæ–šæĄˆã€‚", + "backup_onboarding_description": "åģēč­°æŽĄį”¨ 3-2-1 備äģŊį­–į•Ĩ 來äŋč­ˇæ‚¨įš„čŗ‡æ–™ã€‚æ‚¨æ‡‰äŋį•™åˇ˛ä¸Šå‚ŗįš„ᛏቇ/åŊąį‰‡å‰¯æœŦīŧŒäģĨ及 Immich čŗ‡æ–™åēĢīŧŒäģĨåģēįĢ‹åŽŒæ•´įš„å‚™äģŊæ–šæĄˆã€‚", "backup_onboarding_footer": "更多備äģŊ Immich čŗ‡č¨ŠīŧŒčĢ‹åƒč€ƒčĒĒæ˜Žæ–‡äģļ。", "backup_onboarding_parts_title": "éĩåžžå‚™äģŊ原則 3-2-1īŧš", "backup_onboarding_title": "備äģŊ", "backup_settings": "čŗ‡æ–™åēĢ備äģŊč¨­åŽš", "backup_settings_description": "įŽĄį†čŗ‡æ–™åēĢ備äģŊč¨­åŽšã€‚", "cleared_jobs": "厞åˆĒ除「{job}」äģģ務", - "config_set_by_file": "į›Žå‰įš„č¨­åŽšæ˜¯į”ąč¨­åŽšæĒ”設åޚ", + "config_set_by_file": "į›Žå‰įš„č¨­åŽšæ˜¯į”ąč¨­åŽšæĒ”æ‰€č¨­åޚ", "confirm_delete_library": "您įĸē厚čρåˆĒ除外部åĒ’éĢ”åēĢ {library} 嗎īŧŸ", - "confirm_delete_library_assets": "您įĸē厚čρåˆĒ除此外部åĒ’éĢ”åēĢ嗎īŧŸé€™å°‡åžž Immich 中åˆĒ除 {count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}} īŧŒä¸”į„Ąæŗ•åžŠåŽŸã€‚æĒ”æĄˆäģæœƒäŋį•™åœ¨įĄŦįĸŸä¸­ã€‚", + "confirm_delete_library_assets": "您įĸē厚čρåˆĒ除此åĒ’éĢ”åēĢ嗎īŧŸé€™å°‡åžž Immich 中åˆĒ除 {count, plural, one {# å€‹é …į›Ž} other {所有 # å€‹é …į›Ž}}ä¸”į„Ąæŗ•åžŠåŽŸã€‚æĒ”æĄˆäģæœƒäŋį•™åœ¨įŖįĸŸä¸­ã€‚", "confirm_email_below": "čĢ‹åœ¨åē•下čŧ¸å…Ĩ {email} äģĨįĸēčĒ", "confirm_reprocess_all_faces": "您įĸē厚čĻé‡æ–°č™•į†æ‰€æœ‰č‡‰å­”å—ŽīŧŸé€™æœƒæ¸…除厞å‘Ŋåįš„äēēį‰Šã€‚", "confirm_user_password_reset": "您įĸē厚čĻé‡č¨­ {user} įš„å¯†įĸŧ嗎īŧŸ", "confirm_user_pin_code_reset": "įĸē厚čĻé‡č¨­ {user} įš„ PIN įĸŧ嗎īŧŸ", - "copy_config_to_clipboard_description": "將į•ļ前įŗģįĩąé…å¯˜äŊœį‚ēJSONå°čąĄč¤‡čŖŊ到å‰Ēč˛ŧæŋ", + "copy_config_to_clipboard_description": "å°‡į›Žå‰įŗģįĩąč¨­åޚäģĨ JSON į‰Šäģļæ ŧåŧč¤‡čŖŊ到å‰Ēč˛ŧį°ŋ", "create_job": "åģēįĢ‹äģģ務", "cron_expression": "Cron 表達åŧ", "cron_expression_description": "äŊŋᔍ Cron æ ŧåŧč¨­åŽšæŽƒæé–“éš”ã€‚æ›´å¤ščŗ‡č¨ŠčĢ‹åƒé–ą Crontab Guru", - "cron_expression_presets": "Cron 表達åŧé č¨­į¯„æœŦ", + "cron_expression_presets": "Cron 表達åŧé č¨­å€ŧ", "disable_login": "åœį”¨į™ģå…Ĩ", "duplicate_detection_job_description": "䞝靠æ™ēæ…§æœå°‹ã€‚å°é …į›ŽåŸˇčĄŒæŠŸå™¨å­¸įŋ’䞆åĩæ¸Ŧᛏäŧŧåœ–į‰‡", - "exclusion_pattern_description": "排除čĻå‰‡å¯čŽ“æ‚¨åœ¨æŽƒæåĒ’éĢ”åēĢæ™‚åŋŊį•Ĩį‰šåŽšįš„æĒ”æĄˆå’Œčŗ‡æ–™å¤žã€‚é€™åœ¨æ‚¨æœ‰äē›čŗ‡æ–™å¤žåŒ…åĢä¸æƒŗåŒ¯å…Ĩįš„æĒ”æĄˆīŧˆäž‹åĻ‚ RAW æĒ”īŧ‰æ™‚į‰šåˆĨæœ‰į”¨ã€‚", - "export_config_as_json_description": "將į•ļ前įŗģįĩąé…å¯˜ä¸‹čŧ‰į‚ēJSONæĒ”æĄˆ", - "external_libraries_page_description": "įŽĄį†å¤–éƒ¨åēĢ頁éĸ", + "exclusion_pattern_description": "æŽ’é™¤æ¨Ąåŧå¯čŽ“æ‚¨åœ¨æŽƒæåĒ’éĢ”åēĢæ™‚åŋŊį•Ĩį‰šåŽšæĒ”æĄˆčˆ‡čŗ‡æ–™å¤žã€‚č‹Ĩ某äē›čŗ‡æ–™å¤žåŒ…åĢæ‚¨ä¸æƒŗåŒ¯å…Ĩįš„æĒ”æĄˆīŧˆäž‹åĻ‚ RAW æĒ”īŧ‰īŧŒæ­¤åŠŸčƒŊå°‡éžå¸¸æœ‰į”¨ã€‚", + "export_config_as_json_description": "å°‡į›Žå‰įŗģįĩąč¨­åŽšä¸‹čŧ‰į‚ē JSON æĒ”æĄˆ", + "external_libraries_page_description": "įŽĄį†å¤–éƒ¨åĒ’éĢ”åēĢ頁éĸ", "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": "č­Ļ告īŧšé€™å°‡įĢ‹åŗåˆĒ除äŊŋį”¨č€…åŠå…￉€æœ‰é …į›Žã€‚æ­¤æ“äŊœį„Ąæŗ•æ’¤éŠˇä¸Ļä¸”į„Ąæŗ•é‚„åŽŸåˆĒé™¤įš„æĒ”æĄˆã€‚", + "force_delete_user_warning": "č­Ļ告īŧšé€™å°‡įĢ‹åŗåˆĒ除äŊŋį”¨č€…åŠå…￉€æœ‰é …į›Žã€‚æ­¤å‹•äŊœį„Ąæŗ•垊原īŧŒä¸”į„Ąæŗ•æ‰žå›žåˇ˛åˆĒé™¤įš„æĒ”æĄˆã€‚", "image_format": "æ ŧåŧ", "image_format_description": "WebP čƒŊį”ĸį”Ÿį›¸å°æ–ŧ JPEG æ›´å°įš„æĒ”æĄˆīŧŒäŊ†įˇ¨įĸŧ速åēĻčŧƒæ…ĸ。", "image_fullsize_description": "į§ģ除中įšŧčŗ‡æ–™įš„å¤§å°ē寸åŊąåƒīŧŒåœ¨æ”žå¤§åœ–į‰‡æ™‚äŊŋᔍ", "image_fullsize_enabled": "å•Ÿį”¨å¤§å°ē寸åŊąåƒį”ĸį”Ÿ", - "image_fullsize_enabled_description": "į”ĸį”Ÿéžįļ˛é å‹å–„æ ŧåŧįš„大å°ē寸åŊąåƒã€‚å•Ÿį”¨ã€ŒååĨŊåĩŒå…Ĩįš„é čĻŊ」時īŧŒæœƒį›´æŽĨäŊŋᔍ內åĩŒé čĻŊ而不進行čŊ‰æ›ã€‚不會åŊąéŸŋ JPEG į­‰įļ˛é å‹å–„æ ŧåŧã€‚", + "image_fullsize_enabled_description": "į‚ē非įļ˛é å‹å–„æ ŧåŧį”ĸį”Ÿå¤§å°ēå¯¸į›¸į‰‡ã€‚å•Ÿį”¨ã€ŒååĨŊ內åĩŒé čĻŊ」時īŧŒįŗģįĩąå°‡į›´æŽĨäŊŋᔍ內åĩŒé čĻŊ而不進行čŊ‰įĸŧīŧŒä¸åŊąéŸŋ JPEG į­‰įļ˛é å‹å–„æ ŧåŧã€‚", "image_fullsize_quality_description": "大å°ē寸åŊąåƒå“čŗĒīŧŒį¯„圍į‚ē 1 到 100。數å€ŧčļŠéĢ˜å“čŗĒčļŠåĨŊīŧŒäŊ†æĒ”æĄˆä🿜ƒčļŠå¤§ã€‚", "image_fullsize_title": "大å°ē寸åŊąåƒč¨­åޚ", - "image_prefer_embedded_preview": "偏åĨŊåĩŒå…Ĩįš„é čĻŊ", - "image_prefer_embedded_preview_setting_description": "åœ¨å¯čĄŒįš„æƒ…æŗä¸‹īŧŒå°‡ RAW į…§į‰‡ä¸­įš„å…§åĩŒé čĻŊᔍäŊœåŊąåƒč™•į†įš„čŧ¸å…Ĩ來æēã€‚這對某äē›åŊąåƒčƒŊį”ĸį”Ÿæ›´æē–įĸēįš„č‰˛åŊŠīŧŒäŊ†é čĻŊįš„å“čŗĒ取æąēæ–ŧį›¸æŠŸīŧŒåŊąåƒå¯čƒŊ會å‡ēįžæ›´å¤šåŖ“į¸Žį‘•į–ĩ。", + "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_prefer_wide_gamut_setting_description": "äŊŋᔍ Display P3 čŖŊäŊœį¸Žåœ–。這čƒŊ更åĨŊ地äŋį•™åģŖč‰˛åŸŸåŊąåƒįš„鎎蹔åēĻīŧŒäŊ†åœ¨čˆŠčŖįŊŽčˆ‡čˆŠį‰ˆį€čĻŊ器上īŧŒåŊąåƒå‘ˆįžįš„æ•ˆæžœå¯čƒŊ會有所不同。sRGB åŊąåƒå°‡äŋæŒį‚ē sRGBīŧŒäģĨéŋå…č‰˛åŊŠåį§ģ。", + "image_preview_description": "䏭ᭉå°ē寸åŊąåƒīŧˆä¸åĢ中įšŧčŗ‡æ–™īŧ‰īŧŒį”¨æ–ŧæĒĸčĻ–å–Žä¸€é …į›Žčˆ‡æŠŸå™¨å­¸įŋ’", "image_preview_quality_description": "預čĻŊ品čŗĒį¯„åœį‚ē 1 到 100。數å€ŧčļŠéĢ˜å“čŗĒčļŠåĨŊīŧŒäŊ†æĒ”æĄˆä🿜ƒæ›´å¤§īŧŒä¸Ļ可čƒŊ降äŊŽæ‡‰į”¨į¨‹åŧįš„回應速åēĻã€‚č¨­åŽšéŽäŊŽįš„æ•¸å€ŧ可čƒŊ會åŊąéŸŋ抟器學įŋ’įš„å“čŗĒ。", "image_preview_title": "預čĻŊč¨­åŽš", "image_progressive": "逐æ­Ĩ", - "image_progressive_description": "對JPEGåœ–åƒé€˛čĄŒé€æ­ĨᎍįĸŧīŧŒäģĨå¯Ļįžæŧ¸é€˛åŧåŠ čŧ‰éĄ¯į¤ē。這不會åŊąéŸŋWebP圖像。", + "image_progressive_description": "對 JPEG åŊąåƒé€˛čĄŒæŧ¸é€˛åŧįˇ¨įĸŧīŧŒäģĨå¯Ļįžæŧ¸é€˛åŧčŧ‰å…ĨéĄ¯į¤ē。這不會åŊąéŸŋ WebP åŊąåƒã€‚", "image_quality": "品čŗĒ", "image_resolution": "č§ŖæžåēĻ", "image_resolution_description": "čŧƒéĢ˜įš„č§ŖæžåēĻčƒŊäŋį•™æ›´å¤šį´°į¯€īŧŒäŊ†įˇ¨įĸŧæ™‚é–“æœƒæ›´é•ˇã€æĒ”æĄˆå¤§å°æœƒæ›´å¤§īŧŒä¸Ļ可čƒŊ降äŊŽæ‡‰į”¨į¨‹åŧįš„回應速åēĻ。", "image_settings": "åœ–į‰‡č¨­åŽš", - "image_settings_description": "įŽĄį†į”ĸį”Ÿåœ–į‰‡įš„å“čŗĒå’Œč§ŖæžåēĻ", - "image_thumbnail_description": "į§ģ除中įšŧčŗ‡æ–™įš„å°åž‹į¸Žåœ–īŧŒäģĨᔍæ–ŧæĒĸčĻ–å¤§é‡į…§į‰‡æ™‚äŊŋᔍīŧŒäž‹åĻ‚ä¸ģ時間čģ¸", + "image_settings_description": "įŽĄį†į”ĸį”Ÿįš„åŊąåƒå“čŗĒčˆ‡č§ŖæžåēĻ", + "image_thumbnail_description": "į§ģ除中įšŧčŗ‡æ–™įš„å°åž‹į¸Žåœ–īŧŒäģĨᔍæ–ŧæĒĸčĻ–å¤§é‡į›¸į‰‡æ™‚äŊŋᔍīŧŒäž‹åĻ‚ä¸ģ時間čģ¸", "image_thumbnail_quality_description": "į¸Žåœ–å“čŗĒį¯„åœį‚ē 1 到 100。數å€ŧčļŠéĢ˜å“čŗĒčļŠåĨŊīŧŒäŊ†æĒ”æĄˆä🿜ƒæ›´å¤§īŧŒä¸Ļ可čƒŊ降äŊŽæ‡‰į”¨į¨‹åŧįš„回應速åēĻ。", "image_thumbnail_title": "į¸Žåœ–č¨­åŽš", - "import_config_from_json_description": "é€šéŽä¸Šå‚ŗJSONč¨­åŽšæĒ”å°Žå…Ĩįŗģįĩąé…å¯˜", - "job_concurrency": "{job}äŊĩį™ŧ", + "import_config_from_json_description": "é€éŽä¸Šå‚ŗ JSON č¨­åŽšæĒ”匯å…Ĩįŗģįĩąč¨­åޚ", + "job_concurrency": "{job} ä¸ĻčĄŒæ•¸", "job_created": "厞åģēįĢ‹äģģ務", - "job_not_concurrency_safe": "這個äģģ務äŊĩį™ŧä¸Ļ不厉全。", + "job_not_concurrency_safe": "æ­¤äģģ務不支援ä¸ĻčĄŒåŸˇčĄŒã€‚", "job_settings": "äģģå‹™č¨­åŽš", - "job_settings_description": "äŊĩį™ŧäģģå‹™įŽĄį†", + "job_settings_description": "įŽĄį†äģģ務ä¸ĻčĄŒæ•¸", "jobs_delayed": "{jobCount, plural, other {# 項äģģ務厞åģļ垌}}", "jobs_failed": "{jobCount, plural, other {# 項äģģå‹™åˇ˛å¤ąæ•—}}", - "jobs_over_time": "įĩ„į𔿙‚é–“äģģ務數", + "jobs_over_time": "äģģ務數量čļ¨å‹ĸ", "library_created": "厞åģēįĢ‹åĒ’éĢ”åēĢīŧš{library}", "library_deleted": "åĒ’éĢ”åēĢ厞åˆĒ除", - "library_details": "åĒ’éĢ”åēĢčŠŗæƒ…", - "library_folder_description": "指厚čĻå°Žå…Ĩįš„čŗ‡æ–™å¤žã€‚ å°‡æŽƒææ­¤čŗ‡æ–™å¤žīŧˆåŒ…æ‹Ŧå­čŗ‡æ–™å¤žīŧ‰ä¸­įš„åŊąåƒå’ŒčĻ–é ģ。", - "library_remove_exclusion_pattern_prompt": "您įĸē厚čĻåˆ é™¤æ­¤æŽ’é™¤æ¨Ąåŧå—ŽīŧŸ", - "library_remove_folder_prompt": "您įĸē厚čĻåˆ é™¤æ­¤å°Žå…Ĩčŗ‡æ–™å¤žå—ŽīŧŸ", + "library_details": "åĒ’éĢ”åēĢčŠŗį´°čŗ‡č¨Š", + "library_folder_description": "指厚čρ匝å…Ĩįš„čŗ‡æ–™å¤žã€‚įŗģįĩąå°‡æŽƒææ­¤čŗ‡æ–™å¤žīŧˆåŒ…åĢå­čŗ‡æ–™å¤žīŧ‰ä¸­įš„åŊąåƒčˆ‡åŊąį‰‡ã€‚", + "library_remove_exclusion_pattern_prompt": "įĸē厚čρį§ģé™¤æ­¤æŽ’é™¤æ¨Ąåŧå—ŽīŧŸ", + "library_remove_folder_prompt": "įĸē厚čρį§ģ除此匯å…Ĩčŗ‡æ–™å¤žå—ŽīŧŸ", "library_scanning": "厚期掃描", - "library_scanning_description": "厚期åĒ’éĢ”åēĢæŽƒæč¨­åޚ", + "library_scanning_description": "č¨­åŽšåŽšæœŸåĒ’éĢ”åēĢæŽƒæ", "library_scanning_enable_description": "å•Ÿį”¨åĒ’éĢ”åēĢ厚期掃描", "library_settings": "外部åĒ’éĢ”åēĢ", "library_settings_description": "įŽĄį†å¤–éƒ¨åĒ’éĢ”åēĢč¨­åŽš", @@ -139,9 +139,9 @@ "library_watching_enable_description": "į›ŖæŽ§å¤–éƒ¨åĒ’éĢ”åēĢįš„æĒ”æĄˆčŽŠåŒ–", "library_watching_settings": "åĒ’éĢ”åēĢį›ŖæŽ§[å¯Ļ銗性]", "library_watching_settings_description": "č‡Ēå‹•į›ŖæŽ§æĒ”æĄˆįš„čŽŠåŒ–", - "logging_enable_description": "å•Ÿį”¨æ—ĨčĒŒč¨˜éŒ„", - "logging_level_description": "å•Ÿį”¨æ™‚įš„æ—ĨčĒŒåą¤į´šã€‚", - "logging_settings": "æ—Ĩčnj", + "logging_enable_description": "å•Ÿį”¨į´€éŒ„åŠŸčƒŊ", + "logging_level_description": "å•Ÿį”¨æ™‚įš„į´€éŒ„åą¤į´šã€‚", + "logging_settings": "į´€éŒ„", "machine_learning_availability_checks": "å¯į”¨æ€§æĒĸæŸĨ", "machine_learning_availability_checks_description": "č‡Ē動åĩæ¸Ŧä¸Ļå„Ēå…ˆé¸æ“‡å¯į”¨įš„æŠŸå™¨å­¸įŋ’äŧ翜å™¨", "machine_learning_availability_checks_enabled": "å•Ÿį”¨å¯į”¨æ€§æĒĸæŸĨ", @@ -150,64 +150,64 @@ "machine_learning_availability_checks_timeout": "čĢ‹æą‚čļ…æ™‚", "machine_learning_availability_checks_timeout_description": "å¯į”¨æ€§æĒĸæŸĨčļ…æ™‚īŧˆæ¯Ģį§’īŧ‰", "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": "č‹Ĩåœį”¨īŧŒå‰‡į„ĄčĻ–ä¸‹æ–šįš„č¨­åŽšīŧŒæ‰€æœ‰æŠŸå™¨å­¸įŋ’įš„åŠŸčƒŊéƒŊå°‡åœį”¨ã€‚", + "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_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": "辨識距é›ĸ上限", - "machine_learning_max_recognition_distance_description": "å…Šåŧĩ臉孔čĸĢčĻ–į‚ē同一äēēį‰Šįš„æœ€å¤§čˇé›ĸīŧŒį¯„圍į‚ē 0 臺 2。降äŊŽæ­¤æ•¸å€ŧ可éŋ免將不同äēēį‰Šæ¨™č¨˜į‚ē同一äēēīŧ›æéĢ˜æ­¤æ•¸å€ŧ則可éŋ免將同一äēēį‰Šæ¨™č¨˜į‚ēå…Šå€‹ä¸åŒįš„äēē。čĢ‹æŗ¨æ„īŧŒåˆäŊĩ兊個äēēį‰Šæ¯”å°‡ä¸€å€‹äēēį‰Šæ‹†åˆ†æˆå…Šå€‹æ›´åŽšæ˜“īŧŒå› æ­¤åœ¨å¯čƒŊįš„æƒ…æŗä¸‹īŧŒåģēč­°å°‡æ­¤é–žå€ŧč¨­åŽšåž—čŧƒäŊŽã€‚", + "machine_learning_max_recognition_distance_description": "å…Šåŧĩ臉孔čĸĢčĻ–į‚ē同一äēēį‰Šįš„æœ€å¤§čˇé›ĸīŧŒį¯„圍į‚ē 0 臺 2。降äŊŽæ­¤æ•¸å€ŧ可éŋ免將不同äēēį‰Šæ¨™č¨˜į‚ē同一äēēīŧ›æéĢ˜æ­¤æ•¸å€ŧ則可éŋ免將同一äēēį‰Šæ¨™č¨˜į‚ēå…Šå€‹ä¸åŒįš„äēē。čĢ‹æŗ¨æ„īŧŒåˆäŊĩäēēį‰Šæ¯”æ‹†åˆ†äēēį‰Šæ›´åŽšæ˜“īŧŒå› æ­¤åģēč­°åœ¨å¯čƒŊįš„æƒ…æŗä¸‹å°‡æ­¤é–€æĒģå€ŧč¨­åŽšåž—čŧƒäŊŽã€‚", "machine_learning_min_detection_score": "最äŊŽåĩæ¸Ŧ分數", - "machine_learning_min_detection_score_description": "臉孔åĩæ¸Ŧįš„æœ€äŊŽäŋĄåŋƒåˆ†æ•¸īŧŒį¯„圍į‚ē 0 臺 1。數å€ŧčŧƒäŊŽæ™‚會åĩæ¸Ŧåˆ°æ›´å¤šč‡‰å­”īŧŒäŊ†å¯čƒŊå°Žč‡´čĒ¤åˆ¤ã€‚", + "machine_learning_min_detection_score_description": "臉孔åĩæ¸Ŧįš„æœ€äŊŽäŋĄåŋƒåˆ†æ•¸īŧŒį¯„圍į‚ē 0 - 1。čŧƒäŊŽįš„æ•¸å€ŧ會åĩæ¸Ŧåˆ°æ›´å¤šč‡‰å­”īŧŒäŊ†å¯čƒŊå°Žč‡´čĒ¤åˆ¤ã€‚", "machine_learning_min_recognized_faces": "最äŊŽč‡‰éƒ¨čž¨č­˜æ•¸é‡", "machine_learning_min_recognized_faces_description": "åģēįĢ‹æ–°äēēį‰Šæ‰€éœ€įš„æœ€äŊŽåˇ˛čž¨č­˜č‡‰å­”數量。提éĢ˜æ­¤æ•¸å€ŧå¯čŽ“č‡‰å­”čž¨č­˜æ›´į˛žįĸēīŧŒäŊ†åŒæ™‚會åĸžåР臉孔æœĒčĸĢæŒ‡æ´žįĩĻäģģäŊ•äēēį‰Šįš„å¯čƒŊ性。", "machine_learning_ocr": "æ–‡å­—čž¨č­˜(OCR)", - "machine_learning_ocr_description": "äŊŋį”¨æŠŸå™¨å­¸įŋ’äž†č­˜åˆĨåœ–į‰‡ä¸­įš„æ–‡å­—", + "machine_learning_ocr_description": "äŊŋį”¨æŠŸå™¨å­¸įŋ’čž¨č­˜åŊąåƒä¸­įš„æ–‡å­—", "machine_learning_ocr_enabled": "å•Ÿį”¨OCR", - "machine_learning_ocr_enabled_description": "åĻ‚æžœįρᔍīŧŒåŊąåƒå°‡ä¸æœƒé€˛čĄŒæ–‡å­—č­˜åˆĨ。", - "machine_learning_ocr_max_resolution": "æœ€å¤§åˆ†čž¯įŽ‡", - "machine_learning_ocr_max_resolution_description": "é̘æ–ŧæ­¤åˆ†čž¯įŽ‡įš„é čĻŊ將čĒŋ整大小īŧŒåŒæ™‚äŋæŒį¸ąæŠĢ比。 更éĢ˜įš„å€ŧ更æē–įĸēīŧŒäŊ†č™•į†æ™‚é–“æ›´é•ˇīŧŒäŊ”į”¨æ›´å¤šč¨˜æ†ļéĢ”ã€‚", + "machine_learning_ocr_enabled_description": "č‹Ĩåœį”¨īŧŒåŊąåƒå°‡ä¸æœƒé€˛čĄŒæ–‡å­—čž¨č­˜ã€‚", + "machine_learning_ocr_max_resolution": "æœ€å¤§č§ŖæžåēĻ", + "machine_learning_ocr_max_resolution_description": "č§ŖæžåēĻé̘æ–ŧæ­¤å€ŧįš„é čĻŊåŊąåƒå°‡åœ¨äŋæŒé•ˇå¯Ŧæ¯”įš„æƒ…æŗä¸‹čĒŋ整大小。數å€ŧčļŠé̘čē–įĸēīŧŒäŊ†č™•į†æ™‚é–“æ›´é•ˇä¸”æœƒäŊ”į”¨æ›´å¤šč¨˜æ†ļéĢ”ã€‚", "machine_learning_ocr_min_detection_score": "最äŊŽæĒĸæ¸Ŧ分數", - "machine_learning_ocr_min_detection_score_description": "čρæĒĸæ¸Ŧįš„æ–‡å­—įš„æœ€å°įŊŽäŋĄåēĻ分數į‚ē0-1。 čŧƒäŊŽįš„å€ŧ將æĒĸæ¸Ŧåˆ°æ›´å¤šįš„æ–‡å­—īŧŒäŊ†å¯čƒŊæœƒå°Žč‡´čĒ¤å ąã€‚", - "machine_learning_ocr_min_recognition_score": "最äŊŽč­˜åˆĨ分數", - "machine_learning_ocr_min_score_recognition_description": "æĒĸæ¸Ŧåˆ°įš„æ–‡å­—įš„æœ€å°įŊŽäŋĄåēĻ垗分į‚ē0-1。 čŧƒäŊŽįš„å€ŧå°‡č­˜åˆĨæ›´å¤šįš„æ–‡å­—īŧŒäŊ†å¯čƒŊæœƒå°Žč‡´čĒ¤å ąã€‚", + "machine_learning_ocr_min_detection_score_description": "文字åĩæ¸Ŧįš„æœ€äŊŽäŋĄåŋƒåˆ†æ•¸īŧŒį¯„圍į‚ē 0 - 1。čŧƒäŊŽįš„æ•¸å€ŧ會åĩæ¸Ŧ到更多文字īŧŒäŊ†å¯čƒŊå°Žč‡´čĒ¤åˆ¤ã€‚", + "machine_learning_ocr_min_recognition_score": "最äŊŽčž¨č­˜åˆ†æ•¸", + "machine_learning_ocr_min_score_recognition_description": "厞åĩæ¸Ŧæ–‡å­—įš„æœ€äŊŽčž¨č­˜äŋĄåŋƒåˆ†æ•¸īŧŒį¯„圍į‚ē 0 - 1。čŧƒäŊŽįš„æ•¸å€ŧæœƒčž¨č­˜å‡ē更多文字īŧŒäŊ†å¯čƒŊå°Žč‡´čĒ¤åˆ¤ã€‚", "machine_learning_ocr_model": "OCRæ¨Ąåž‹", - "machine_learning_ocr_model_description": "æœå‹™å™¨æ¨Ąåž‹æ¯”į§ģå‹•æ¨Ąåž‹æ›´æē–įĸēīŧŒäŊ†éœ€čĻæ›´é•ˇįš„æ™‚é–“äž†č™•į†å’ŒäŊŋį”¨æ›´å¤šįš„č¨˜æ†ļéĢ”ã€‚", + "machine_learning_ocr_model_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_smart_search_enabled_description": "č‹Ĩåœį”¨īŧŒåŊąåƒå°‡ä¸æœƒé€˛čĄŒæ™ēæ…§æœå°‹įˇ¨įĸŧ。", "machine_learning_url_description": "抟器學įŋ’äŧ翜å™¨įš„ URL。č‹Ĩ提䞛多個 URLīŧŒįŗģįĩ࿜ƒäžåēé€ä¸€å˜—čŠĻīŧŒį›´åˆ°å…ļ中一č‡ē成功回應į‚ēæ­ĸīŧˆį”ąå‰åˆ°åžŒīŧ‰ã€‚æœĒå›žæ‡‰įš„äŧ翜å™¨å°‡čĸĢæšĢ時åŋŊį•ĨīŧŒį›´åˆ°å…ļé‡æ–°ä¸Šįˇšã€‚", "maintenance_delete_backup": "åˆĒ除備äģŊ", - "maintenance_delete_backup_description": "此文äģļ將čĸĢæ°¸äš…åˆĒ除。", + "maintenance_delete_backup_description": "æ­¤æĒ”æĄˆå°‡čĸĢæ°¸äš…åˆĒé™¤ä¸”į„Ąæŗ•åžŠåŽŸã€‚", "maintenance_delete_error": "åˆĒ除備äģŊå¤ąæ•—ã€‚", - "maintenance_restore_backup": "æĸ垊備äģŊ", - "maintenance_restore_backup_description": "Immich數據將čĸĢčĢ‹å‡ēīŧŒåšļåžžé¸åŽšįš„å‚™äģŊ中æĸ垊。在įšŧįēŒäš‹å‰īŧŒå°‡å…ˆå‰ĩåģē一個į•ļå‰æ•¸æ“šįš„å‚™äģŊ。", - "maintenance_restore_backup_different_version": "此備äģŊæ˜¯į”ąä¸åŒį‰ˆæœŦįš„Immichå‰ĩåģēįš„īŧ", + "maintenance_restore_backup": "還原備äģŊ", + "maintenance_restore_backup_description": "Immich įš„čŗ‡æ–™å°‡čĸĢæ¸…除īŧŒä¸Ļåžžé¸å–įš„å‚™äģŊ還原。在įšŧį猿“äŊœå‰īŧŒįŗģįĩ࿜ƒå…ˆåģēįĢ‹į›Žå‰įš„čŗ‡æ–™å‚™äģŊ。", + "maintenance_restore_backup_different_version": "此備äģŊæ˜¯į”ąä¸åŒį‰ˆæœŦįš„ Immich 所åģēįĢ‹īŧ", "maintenance_restore_backup_unknown_version": "į„Ąæŗ•įĸē厚備äģŊį‰ˆæœŦ。", - "maintenance_restore_database_backup": "æĸ垊數據åēĢ備äģŊ", - "maintenance_restore_database_backup_description": "äŊŋᔍ備äģŊ文äģļ將數據åēĢ回æģžåˆ°čŧƒæ—Šįš„į‹€æ…‹", + "maintenance_restore_database_backup": "é‚„åŽŸčŗ‡æ–™åēĢ備äģŊ", + "maintenance_restore_database_backup_description": "äŊŋᔍ備äģŊæĒ”æĄˆå°‡čŗ‡æ–™åēĢé‚„åŽŸč‡ŗčŧƒæ—Šįš„į‹€æ…‹", "maintenance_settings": "įļ­č­ˇ", - "maintenance_settings_description": "將ImmichįŊŽæ–ŧįļ­č­ˇæ¨Ąåŧã€‚", + "maintenance_settings_description": "將 Immich åˆ‡æ›č‡ŗįļ­č­ˇæ¨Ąåŧã€‚", "maintenance_start": "啟動įļ­č­ˇæ¨Ąåŧ", "maintenance_start_error": "啟動įļ­č­ˇæ¨Ąåŧå¤ąæ•—。", - "maintenance_upload_backup": "ä¸Šå‚ŗæ•¸æ“šåēĢ備äģŊ文äģļ", - "maintenance_upload_backup_error": "į„Ąæŗ•ä¸Šå‚ŗå‚™äģŊīŧŒåŽƒæ˜¯.sql或.sql.gzæ ŧåŧįš„æ–‡äģļ嗎īŧŸ", - "manage_concurrency": "įŽĄį†äŊĩį™ŧ", - "manage_concurrency_description": "導čˆĒ到äģģ務頁éĸäģĨįŽĄį†äģģ務äŊĩį™ŧ性", - "manage_log_settings": "įŽĄį†æ—ĨčĒŒč¨­åŽš", + "maintenance_upload_backup": "ä¸Šå‚ŗčŗ‡æ–™åēĢ備äģŊæĒ”æĄˆ", + "maintenance_upload_backup_error": "į„Ąæŗ•ä¸Šå‚ŗå‚™äģŊīŧŒåŽƒæ˜¯ .sql 或 .sql.gz æ ŧåŧįš„æĒ”æĄˆå—ŽīŧŸ", + "manage_concurrency": "įŽĄį†ä¸ĻčĄŒč¨­åŽš", + "manage_concurrency_description": "前垀äģģ務頁éĸäģĨįŽĄį†äģģ務ä¸ĻčĄŒč¨­åŽš", + "manage_log_settings": "įŽĄį†į´€éŒ„č¨­åŽš", "map_dark_style": "æˇąč‰˛æ¨Ŗåŧ", "map_enable_description": "å•Ÿį”¨åœ°åœ–åŠŸčƒŊ", "map_gps_settings": "åœ°åœ–čˆ‡ GPS č¨­åŽš", @@ -224,21 +224,21 @@ "memory_cleanup_job": "回æ†ļæ¸…į†", "memory_generate_job": "į”ĸį”Ÿå›žæ†ļ", "metadata_extraction_job": "æ“ˇå–ä¸­įšŧčŗ‡æ–™", - "metadata_extraction_job_description": "垞每個åĒ’éĢ”æĒ”æĄˆä¸­æ“ˇå–ä¸­įšŧčŗ‡æ–™čŗ‡č¨ŠīŧŒäž‹åĻ‚ GPSã€č‡‰å­”čˆ‡č§ŖæžåēĻ", + "metadata_extraction_job_description": "åžžæ¯å€‹é …į›Žä¸­æ“ˇå–ä¸­įšŧčŗ‡æ–™čŗ‡č¨ŠīŧŒäž‹åĻ‚ GPSã€č‡‰å­”čˆ‡č§ŖæžåēĻ", "metadata_faces_import_setting": "å•Ÿį”¨č‡‰å­”åŒ¯å…Ĩ", - "metadata_faces_import_setting_description": "åžžåŊąåƒ EXIF čŗ‡æ–™čˆ‡å´æŽĨæĒ”æĄˆåŒ¯å…Ĩ臉孔", + "metadata_faces_import_setting_description": "åžžåŊąåƒ EXIF čŗ‡æ–™čˆ‡ Sidecar æĒ”æĄˆåŒ¯å…Ĩ臉孔", "metadata_settings": "中įšŧčŗ‡æ–™č¨­åŽš", "metadata_settings_description": "įŽĄį†ä¸­įšŧčŗ‡æ–™č¨­åŽš", "migration_job": "遡į§ģ", - "migration_job_description": "將åĒ’éĢ”æĒ”æĄˆčˆ‡č‡‰å­”įš„į¸Žåœ–éˇį§ģč‡ŗæœ€æ–°įš„čŗ‡æ–™å¤žįĩæ§‹", + "migration_job_description": "å°‡é …į›Žčˆ‡č‡‰å­”į¸Žåœ–éˇį§ģč‡ŗæœ€æ–°įš„čŗ‡æ–™å¤žįĩæ§‹", "nightly_tasks_cluster_faces_setting_description": "對新åĩæ¸Ŧåˆ°įš„č‡‰å­”åŸˇčĄŒč‡‰å­”čž¨č­˜", "nightly_tasks_cluster_new_faces_setting": "į‚ēæ–°č‡‰å­”é€˛čĄŒåˆ†įž¤", "nightly_tasks_database_cleanup_setting": "čŗ‡æ–™åēĢæ¸…ᐆäŊœæĨ­", "nightly_tasks_database_cleanup_setting_description": "æ¸…é™¤čŗ‡æ–™åēĢä¸­čˆŠįš„čˆ‡åˇ˛éŽæœŸįš„čŗ‡æ–™", "nightly_tasks_generate_memories_setting": "į”ĸį”Ÿå›žæ†ļ", - "nightly_tasks_generate_memories_setting_description": "åžžåĒ’éĢ”æĒ”æĄˆåģēįĢ‹æ–°å›žæ†ļ", + "nightly_tasks_generate_memories_setting_description": "åžžé …į›ŽåģēįĢ‹æ–°å›žæ†ļ", "nightly_tasks_missing_thumbnails_setting": "į”ĸį”Ÿįŧēå°‘įš„į¸Žåœ–", - "nightly_tasks_missing_thumbnails_setting_description": "å°‡æ˛’æœ‰į¸Žåœ–įš„åĒ’éĢ”æĒ”æĄˆæŽ’å…ĨäŊ‡åˆ—äģĨį”ĸį”Ÿį¸Žåœ–", + "nightly_tasks_missing_thumbnails_setting_description": "將įŧēå°‘į¸Žåœ–įš„é …į›ŽæŽ’å…ĨäŊ‡åˆ—äģĨį”ĸį”Ÿį¸Žåœ–", "nightly_tasks_settings": "夜間äģģå‹™č¨­åŽš", "nightly_tasks_settings_description": "įŽĄį†å¤œé–“äģģ務", "nightly_tasks_start_time_setting": "開始時間", @@ -247,7 +247,7 @@ "nightly_tasks_sync_quota_usage_setting_description": "æ šæ“šį›Žå‰įš„äŊŋį”¨é‡æ›´æ–°äŊŋį”¨č€…įš„å„˛å­˜é…éĄ", "no_paths_added": "æ˛’æœ‰åˇ˛æ–°åĸžįš„čˇ¯åž‘", "no_pattern_added": "尚æœĒ新åĸžæŽ’除čĻå‰‡", - "note_apply_storage_label_previous_assets": "提į¤ēīŧšč‹ĨčĻå°‡å„˛å­˜æ¨™įą¤åĨ—į”¨åˆ°å…ˆå‰ä¸Šå‚ŗįš„åĒ’éĢ”æĒ”æĄˆīŧŒčĢ‹åŸˇčĄŒ", + "note_apply_storage_label_previous_assets": "提į¤ēīŧšč‹ĨčĻå°‡å„˛å­˜æ¨™įą¤åĨ—į”¨č‡ŗå…ˆå‰ä¸Šå‚ŗįš„é …į›ŽīŧŒčĢ‹åŸˇčĄŒ", "note_cannot_be_changed_later": "æŗ¨æ„īŧ𿭤荭åޚæ—ĨåžŒį„Ąæŗ•čŽŠæ›´īŧ", "notification_email_from_address": "寄äģļ地址", "notification_email_from_address_description": "寄äģļ者é›ģ子éƒĩäģļ地址īŧŒäž‹åĻ‚īŧš\"Immich Photo Server \"。čĢ‹įĸēäŋäŊŋį”¨įš„æ˜¯æ‚¨æœ‰æŦŠé™å¯„送éƒĩäģļįš„åœ°å€ã€‚", @@ -255,7 +255,7 @@ "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_port_description": "é›ģ子éƒĩäģļäŧ翜å™¨įš„逪æŽĨ埠īŧˆäž‹åĻ‚ 25、465 或 587īŧ‰", "notification_email_secure": "SMTPS", "notification_email_secure_description": "äŊŋᔍSMTPSīŧˆåŸēæ–ŧTLSįš„SMTPīŧ‰", "notification_email_sent_test_email_button": "傺送æ¸ŦčŠĻé›ģ子éƒĩäģļä¸Ļå„˛å­˜", @@ -272,7 +272,7 @@ "oauth_auto_register": "č‡Ē動č¨ģ冊", "oauth_auto_register_description": "äŊŋᔍ OAuth į™ģå…Ĩ垌č‡Ē動č¨ģ冊新äŊŋᔍ者", "oauth_button_text": "按鈕文字", - "oauth_client_secret_description": "åĻ‚æžœ OAuth æäž›č€…ä¸æ”¯æ´ PKCEīŧˆæŽˆæŦŠįĸŧ驗證įĸŧä礿›æŠŸåˆļīŧ‰īŧŒå‰‡æ­¤į‚ēåŋ…åĄĢé …į›Žã€‚", + "oauth_client_secret_description": "æŠŸå¯†į”¨æˆļįĢ¯įš„åŋ…åĄĢé …į›Žīŧ›č‹Ĩå…Ŧ開ᔍæˆļįĢ¯ä¸æ”¯æ´ PKCE (äģŖįĸŧä礿›įš„éЗ證金鑰)īŧŒäēĻ須åĄĢå¯Ģ。", "oauth_enable_description": "äŊŋᔍ OAuth į™ģå…Ĩ", "oauth_mobile_redirect_uri": "čĄŒå‹•įĢ¯é‡æ–°å°Žå‘ URI", "oauth_mobile_redirect_uri_override": "čĻ†č“‹čĄŒå‹•įĢ¯é‡æ–°å°Žå‘ URI", @@ -290,7 +290,7 @@ "oauth_storage_quota_default_description": "æœĒæäž›åŽŖå‘Šæ™‚æ‰€äŊŋį”¨įš„é…éĄīŧˆGiBīŧ‰ã€‚", "oauth_timeout": "čĢ‹æą‚é€žæ™‚", "oauth_timeout_description": "čĢ‹æą‚įš„é€žæ™‚æ™‚é–“īŧˆæ¯Ģį§’īŧ‰", - "ocr_job_description": "äŊŋį”¨æŠŸå™¨å­¸įŋ’äž†č­˜åˆĨåœ–į‰‡ä¸­įš„æ–‡å­—", + "ocr_job_description": "äŊŋį”¨æŠŸå™¨å­¸įŋ’čž¨č­˜åŊąåƒä¸­įš„æ–‡å­—", "password_enable_description": "äŊŋᔍé›ģ子éƒĩäģļ和密įĸŧį™ģå…Ĩ", "password_settings": "密įĸŧį™ģå…Ĩ", "password_settings_description": "įŽĄį†å¯†įĸŧį™ģå…Ĩč¨­åŽš", @@ -311,38 +311,38 @@ "search_jobs": "搜尋äģģ務â€Ļ", "send_welcome_email": "å‚ŗé€æ­ĄčŋŽé›ģ子éƒĩäģļ", "server_external_domain_settings": "外部įļ˛åŸŸ", - "server_external_domain_settings_description": "å…Ŧ開分äēĢ逪įĩįš„įļ˛åŸŸīŧŒåŒ…åĢ http(s)://", + "server_external_domain_settings_description": "å…Ŧ開分äēĢ逪įĩįš„įļ˛åŸŸ", "server_public_users": "å…Ŧ開äŊŋᔍ者", - "server_public_users_description": "在將äŊŋį”¨č€…æ–°åĸžåˆ°å…ąäēĢᛏį°ŋ時īŧŒæœƒåˆ—å‡ē所有äŊŋį”¨č€…įš„å§“åčˆ‡é›ģ子éƒĩäģļã€‚åœį”¨æ­¤åŠŸčƒŊ垌īŧŒäŊŋį”¨č€…æ¸…å–Žå°‡åƒ…äž›įŗģįĩąįŽĄį†å“ĄæĒĸčĻ–ã€‚", + "server_public_users_description": "將äŊŋį”¨č€…æ–°åĸžč‡ŗå…ąäēĢᛏį°ŋ時īŧŒæœƒåˆ—å‡ē所有äŊŋᔍ者īŧˆå§“åčˆ‡é›ģ子éƒĩäģļīŧ‰ã€‚č‹Ĩåœį”¨īŧŒäŊŋį”¨č€…æ¸…å–Žå°‡åƒ…äž›įŽĄį†å“ĄæŸĨįœ‹ã€‚", "server_settings": "äŧ翜å™¨č¨­åޚ", "server_settings_description": "įŽĄį†äŧ翜å™¨č¨­åޚ", - "server_stats_page_description": "įŽĄį†æœå‹™å™¨įĩąč¨ˆé éĸ", + "server_stats_page_description": "įŽĄį†äŧ翜å™¨įĩąč¨ˆé éĸ", "server_welcome_message": "æ­ĄčŋŽč¨Šæ¯", "server_welcome_message_description": "在į™ģå…Ĩ頁éĸéĄ¯į¤ēįš„č¨Šæ¯ã€‚", "settings_page_description": "įŽĄį†č¨­åŽšé éĸ", "sidecar_job": "側æŽĨæĒ”æĄˆä¸­įšŧčŗ‡æ–™", "sidecar_job_description": "åžžæĒ”æĄˆįŗģįĩąåĩæ¸Ŧ或同æ­Ĩ側æŽĨæĒ”æĄˆä¸­įšŧčŗ‡æ–™", "slideshow_duration_description": "每åŧĩåœ–į‰‡æ”žæ˜ įš„į§’æ•¸", - "smart_search_job_description": "åŸˇčĄŒæŠŸå™¨å­¸įŋ’有劊æ–ŧæ™ē慧搜尋", + "smart_search_job_description": "å°é …į›ŽåŸˇčĄŒæŠŸå™¨å­¸įŋ’äģĨ支援æ™ē慧搜尋", "storage_template_date_time_description": "æĒ”æĄˆįš„åģēįĢ‹æ™‚é–“æˆŗæœƒį”¨æ–ŧæ—ĨæœŸčˆ‡æ™‚é–“čŗ‡č¨Š", "storage_template_date_time_sample": "å–æ¨Ŗæ™‚é–“ {date}", "storage_template_enable_description": "å•Ÿį”¨å„˛å­˜į¯„æœŦåŧ•擎", "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_migration_description": "åĨ—į”¨į›Žå‰įš„ {template} č‡ŗå…ˆå‰ä¸Šå‚ŗįš„é …į›Ž", + "storage_template_migration_info": "å„˛å­˜į¯„æœŦ會將所有副æĒ”名čŊ‰æ›į‚ē小å¯Ģã€‚į¯„æœŦčŽŠæ›´åƒ…æœƒåĨ—į”¨č‡ŗæ–°é …į›Žã€‚č‹ĨčρčŋŊæē¯åĨ—ᔍᝄæœŦč‡ŗå…ˆå‰ä¸Šå‚ŗįš„é …į›ŽīŧŒčĢ‹åŸˇčĄŒ {job}。", + "storage_template_migration_job": "å„˛å­˜į¯„æœŦ遡į§ģäŊœæĨ­", "storage_template_more_details": "關æ–ŧ此功čƒŊįš„æ›´å¤ščŠŗį´°čŗ‡č¨ŠīŧŒčĢ‹åƒé–ąå„˛å­˜į¯„æœŦ及å…ļåŊąéŸŋ", - "storage_template_onboarding_description_v2": "å•Ÿį”¨åžŒīŧŒæ­¤åŠŸčƒŊ會䞝äŊŋᔍ者č‡Ēč¨‚įš„į¯„æœŦč‡Ēå‹•æ•´į†æĒ”æĄˆã€‚æ›´å¤ščŗ‡č¨ŠčĢ‹åƒé–ąčĒĒæ˜Žæ–‡äģļ。", + "storage_template_onboarding_description_v2": "å•Ÿį”¨åžŒīŧŒæ­¤åŠŸčƒŊ將䞝據äŊŋᔍ者č‡Ē荂ᝄæœŦč‡Ēå‹•æ•´į†æĒ”æĄˆã€‚æ›´å¤ščŗ‡č¨ŠčĢ‹åƒé–ąčĒĒæ˜Žæ–‡äģļ。", "storage_template_path_length": "預äŧ°čˇ¯åž‘镡åēĻ上限īŧš{length, number}/{limit, number}", "storage_template_settings": "å„˛å­˜į¯„æœŦ", - "storage_template_settings_description": "įŽĄį†ä¸Šå‚ŗæĒ”æĄˆįš„čŗ‡æ–™å¤žįĩæ§‹å’ŒæĒ”名", + "storage_template_settings_description": "įŽĄį†ä¸Šå‚ŗé …į›Žįš„čŗ‡æ–™å¤žįĩæ§‹čˆ‡æĒ”名", "storage_template_user_label": "{label} 是äŊŋį”¨č€…įš„å„˛å­˜æ¨™įą¤", "system_settings": "įŗģįĩąč¨­åޚ", "tag_cleanup_job": "æ¸…į†æ¨™įą¤", - "template_email_available_tags": "您可äģĨåœ¨æ‚¨įš„į¯„æœŦ中äŊŋᔍäģĨä¸‹čŽŠæ•¸īŧš{tags}", - "template_email_if_empty": "åĻ‚æžœį¯„æœŦį‚ēįŠēīŧŒå°‡äŊŋᔍ預荭é›ģ子éƒĩäģļᝄæœŦ。", + "template_email_available_tags": "您可äģĨåœ¨į¯„æœŦ中äŊŋį”¨ä¸‹åˆ—čŽŠæ•¸īŧš{tags}", + "template_email_if_empty": "č‹ĨᝄæœŦ內厚į‚ēįŠēīŧŒå‰‡æœƒäŊŋᔍ預荭éƒĩäģļᝄæœŦ。", "template_email_invite_album": "ᛏį°ŋ邀č̋ᝄæœŦ", "template_email_preview": "預čĻŊ", "template_email_settings": "é›ģ子éƒĩäģļᝄæœŦ", @@ -351,13 +351,13 @@ "template_settings": "通įŸĨᝄæœŦ", "template_settings_description": "įŽĄį†é€šįŸĨįš„č‡Ē荂ᝄæœŦ", "theme_custom_css_settings": "č‡Ē訂 CSS", - "theme_custom_css_settings_description": "可äģĨį”¨åą¤į–Šæ¨ŖåŧčĄ¨īŧˆCSSīŧ‰äž†č‡Ē訂 Immich įš„č¨­č¨ˆã€‚", + "theme_custom_css_settings_description": "é€éŽéšŽåą¤åŧæ¨ŖåŧčĄ¨ (CSS) åŗå¯č‡Ē訂 Immich įš„å¤–č§€č¨­č¨ˆã€‚", "theme_settings": "ä¸ģéĄŒč¨­åŽš", "theme_settings_description": "č‡Ē訂 Immich įš„įļ˛é äģ‹éĸ", "thumbnail_generation_job": "į”ĸį”Ÿį¸Žåœ–", - "thumbnail_generation_job_description": "į‚ē每個æĒ”æĄˆį”ĸį”Ÿå¤§ã€å°åŠæ¨ĄįŗŠį¸Žåœ–īŧŒäšŸį‚ē每äŊäēēį‰Šį”ĸį”Ÿį¸Žåœ–", + "thumbnail_generation_job_description": "į‚翝å€‹é …į›Žį”ĸį”Ÿå¤§ã€å°åŠæ¨ĄįŗŠį¸Žåœ–īŧŒäšŸį‚ē每äŊäēēį‰Šį”ĸį”Ÿį¸Žåœ–", "transcoding_acceleration_api": "加速 API", - "transcoding_acceleration_api_description": "æ­¤ API 會äŊŋį”¨æ‚¨įš„įĄŦéĢ”äģĨ加速čŊ‰įĸŧæĩį¨‹ã€‚æ­¤č¨­åŽšæŽĄã€Œį›ĄåŠ›č€Œį‚ēã€æ¨Ąåŧâ€”—č‹ĨčŊ‰įĸŧå¤ąæ•—īŧŒå°‡æœƒå›žé€€č‡ŗčģŸéĢ”čŊ‰įĸŧ。VP9 是åĻčƒŊ運äŊœīŧŒå–æąēæ–ŧæ‚¨įš„įĄŦéĢ”č¨­åŽšã€‚", + "transcoding_acceleration_api_description": "æ­¤ API å°‡čˆ‡æ‚¨įš„čŖįŊŽäē’å‹•äģĨ加速čŊ‰įĸŧã€‚æ­¤č¨­åŽšæŽĄã€Œį›ĄåŠ›č€Œį‚ēã€æ¨Ąåŧīŧšč‹Ĩå¤ąæ•—å°‡å›žé€€č‡ŗčģŸéĢ”čŊ‰įĸŧ。VP9 是åĻå¯į”¨å–æąēæ–ŧįĄŦéĢ”ã€‚", "transcoding_acceleration_nvenc": "NVENCīŧˆéœ€čρ NVIDIA GPUīŧ‰", "transcoding_acceleration_qsv": "Quick Syncīŧˆéœ€čρįŦŦ 7 äģŖæˆ–æ›´æ–°įš„ Intel 處ᐆ噍īŧ‰", "transcoding_acceleration_rkmpp": "RKMPPīŧˆåƒ…éŠį”¨æ–ŧ Rockchip SOCsīŧ‰", @@ -367,17 +367,17 @@ "transcoding_accepted_containers": "可æŽĨå—įš„å°čŖæ ŧåŧ", "transcoding_accepted_containers_description": "選擇å“Ēäē›å°čŖæ ŧåŧä¸éœ€čĻé‡æ–°å°čŖīŧˆremuxīŧ‰į‚ē 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": "æ˜¯éŸŗčŗĒ最äŊŗįš„選項īŧŒäŊ†čˆ‡čˆŠčŖįŊŽæˆ–čˆŠį‰ˆčģŸéĢ”įš„į›¸åŽšæ€§čŧƒäŊŽã€‚", + "transcoding_audio_codec_description": "Opus æ˜¯éŸŗčŗĒ最äŊŗįš„選項īŧŒäŊ†čˆ‡čˆŠčŖįŊŽæˆ–čˆŠį‰ˆčģŸéĢ”įš„į›¸åŽšæ€§čŧƒäŊŽã€‚", "transcoding_bitrate_description": "äŊå…ƒįއé̘æ–ŧ最大å€ŧ或æ ŧåŧä¸åœ¨å¯æŽĨå—į¯„åœįš„åŊąį‰‡", - "transcoding_codecs_learn_more": "åĻ‚éœ€é€˛ä¸€æ­Ĩäē†č§Ŗæ­¤č™•äŊŋį”¨įš„čĄ“čĒžīŧŒčĢ‹åƒé–ą FFmpeg 文äģļ中關æ–ŧ H.264 ᎍ觪įĸŧ器、HEVC ᎍ觪įĸŧ器 及 VP9 ᎍ觪įĸŧ器 įš„čĒĒæ˜Žã€‚", + "transcoding_codecs_learn_more": "åĻ‚éœ€é€˛ä¸€æ­Ĩäē†č§Ŗæ­¤č™•äŊŋį”¨įš„čĄ“čĒžīŧŒčĢ‹åƒé–ą FFmpeg čĒĒæ˜Žæ–‡äģļ中關æ–ŧ H.264 ᎍ觪įĸŧ器、HEVC ᎍ觪įĸŧ器 及 VP9 ᎍ觪įĸŧ器 įš„čĒĒæ˜Žã€‚", "transcoding_constant_quality_mode": "恆厚品čŗĒæ¨Ąåŧ", "transcoding_constant_quality_mode_description": "ICQ įš„æ•ˆæžœå„Ēæ–ŧ CQPīŧŒäŊ†éƒ¨åˆ†įĄŦéĢ”åŠ é€ŸčŖįŊŽä¸æ”¯æ´æ­¤æ¨Ąåŧã€‚č¨­åŽšæ­¤é¸é …æ™‚īŧŒåœ¨äŊŋᔍäģĨ品čŗĒį‚ēåŸēæē–įš„įˇ¨įĸŧ時會å„Ēå…ˆæŽĄį”¨æ‰€æŒ‡åŽšįš„æ¨Ąåŧã€‚NVENC 不支援 ICQīŧŒå› æ­¤æ­¤č¨­åޚ圍 NVENC 下會čĸĢåŋŊį•Ĩ。", "transcoding_constant_rate_factor": "æ†åŽšé€ŸįŽ‡å› å­īŧˆ-crfīŧ‰", "transcoding_constant_rate_factor_description": "čĻ–č¨Šå“čŗĒį­‰į´šã€‚å…¸åž‹å€ŧį‚ē H.264 įš„ 23、HEVC įš„ 28、VP9 įš„ 31 和 AV1 įš„ 35。數å€ŧčļŠäŊŽīŧŒå“čŗĒčļŠåĨŊīŧŒäŊ†æœƒį”ĸį”Ÿčŧƒå¤§įš„æĒ”æĄˆã€‚", - "transcoding_disabled_description": "不對äģģäŊ•åŊąį‰‡é€˛čĄŒčŊ‰įĸŧīŧŒå¯čƒŊæœƒå°Žč‡´éƒ¨åˆ†į”¨æˆļįĢ¯į„Ąæŗ•æ­Ŗå¸¸æ’­æ”ž", + "transcoding_disabled_description": "不對äģģäŊ•åŊąį‰‡é€˛čĄŒčŊ‰įĸŧīŧŒé€™å¯čƒŊæœƒå°Žč‡´éƒ¨åˆ†į”¨æˆļįĢ¯į„Ąæŗ•æ­Ŗå¸¸æ’­æ”ž", "transcoding_encoding_options": "ᎍįĸŧ選項", "transcoding_encoding_options_description": "č¨­åŽšįˇ¨įĸŧåŊąį‰‡įš„ᎍ觪įĸŧå™¨ã€č§ŖæžåēĻ、品čŗĒ和å…ļäģ–選項", "transcoding_hardware_acceleration": "įĄŦéĢ”åŠ é€Ÿ", @@ -387,16 +387,16 @@ "transcoding_max_b_frames": "最大 B 嚀數", "transcoding_max_b_frames_description": "čŧƒéĢ˜įš„æ•¸å€ŧå¯æå‡åŖ“į¸Žæ•ˆįŽ‡īŧŒäŊ†æœƒé™äŊŽįˇ¨įĸŧ速åēĻ。在čŧƒčˆŠįš„čŖįŊŽä¸ŠīŧŒå¯čƒŊ與įĄŦéĢ”åŠ é€Ÿä¸į›¸åŽšã€‚0 äģŖčĄ¨åœį”¨ B 嚀īŧŒč€Œ -1 則會č‡Ēå‹•č¨­åŽšæ­¤æ•¸å€ŧ。", "transcoding_max_bitrate": "最大äŊå…ƒé€Ÿįއ", - "transcoding_max_bitrate_description": "č¨­åŽšæœ€å¤§äŊå…ƒįŽ‡å¯äģĨ在čŧ•åžŽįŠ§į‰˛å“čŗĒįš„æƒ…æŗä¸‹īŧŒčŽ“æĒ”æĄˆå¤§å°æ›´åŽšæ˜“é æ¸Ŧ。在 720p č§ŖæžåēĻ下īŧŒVP9 或 HEVC įš„å…¸åž‹å€ŧį‚ē 2600 kbit/sīŧŒH.264 則į‚ē 4500 kbit/sã€‚č¨­į‚ē 0 å‰‡åœį”¨æ­¤åŠŸčƒŊ。į•￞’有指厚įĩ„į𔿙‚īŧŒå‡č¨­kīŧˆäģŖčĄ¨kbit/sīŧ‰īŧ› 囙此īŧŒ5000、5000k和5MīŧˆMbit/sīŧ‰æ˜¯į­‰æ•ˆįš„。", + "transcoding_max_bitrate_description": "č¨­åŽšæœ€å¤§äŊå…ƒįއčƒŊ讓æĒ”æĄˆå¤§å°æ›´įŠŠåŽšīŧŒäŊ†æœƒį¨åžŽįЧ቞品čŗĒ。720p ä¸‹įš„å…¸åž‹å€ŧį‚ēīŧšVP9 或 HEVC į‚ē 2600 kbit/sīŧŒH.264 į‚ē 4500 kbit/sã€‚č¨­į‚ē 0 å‰‡åœį”¨ã€‚č‹ĨæœĒ指厚喎äŊīŧŒįŗģįĩąå°‡é č¨­į‚ē k (åŗ kbit/s)īŧ›å› æ­¤ 5000、5000k 與 5M (åŗ Mbit/s) æ˜¯į­‰æ•ˆįš„ã€‚", "transcoding_max_keyframe_interval": "最大關éĩ嚀間隔", - "transcoding_max_keyframe_interval_description": "č¨­åŽšé—œéĩåš€äš‹é–“įš„æœ€å¤§åš€čˇã€‚čŧƒäŊŽįš„å€ŧ會降äŊŽåŖ“į¸Žæ•ˆįŽ‡īŧŒäŊ†å¯äģĨ攚善搜尋時間īŧŒä¸Ļ有可čƒŊ會攚善åŋĢé€ŸčŽŠå‹•å ´æ™¯įš„å“čŗĒ。0 會č‡Ēå‹•č¨­åŽšæ­¤å€ŧ。", + "transcoding_max_keyframe_interval_description": "č¨­åŽšé—œéĩåš€äš‹é–“įš„æœ€å¤§åš€čˇã€‚čŧƒäŊŽįš„æ•¸å€ŧ會降äŊŽåŖ“į¸Žæ•ˆįŽ‡īŧŒäŊ†å¯æ”šå–„莺čŊ‰æœå°‹æ™‚é–“ä¸Ļ提升éĢ˜å‹•æ…‹å ´æ™¯å“čŗĒ。0 į‚ēč‡Ēå‹•č¨­åŽšã€‚", "transcoding_optimal_description": "é̘æ–ŧį›Žæ¨™č§ŖæžåēĻæˆ–æ ŧåŧä¸åœ¨å¯æŽĨå—į¯„åœįš„åŊąį‰‡", "transcoding_policy": "čŊ‰įĸŧį­–į•Ĩ", "transcoding_policy_description": "č¨­åŽšåŊąį‰‡é€˛čĄŒčŊ‰įĸŧįš„æĸäģļ", "transcoding_preferred_hardware_device": "éϖ遏įĄŦéĢ”čŖįŊŽ", "transcoding_preferred_hardware_device_description": "åƒ…éŠį”¨æ–ŧ VAAPI 和 QSVã€‚č¨­åŽšį”¨æ–ŧįĄŦéĢ”čŊ‰įĸŧįš„ dri ᝀéģžã€‚", "transcoding_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": "僅限æ ŧåŧä¸čĸĢæŽĨå—įš„åŊąį‰‡", @@ -407,29 +407,29 @@ "transcoding_temporal_aq": "時間č‡Ē遊應量化īŧˆTemporal AQīŧ‰", "transcoding_temporal_aq_description": "åƒ…éŠį”¨æ–ŧ NVENCīŧŒæ™‚域č‡Ē我čĒŋ整量化可提升éĢ˜į´°į¯€ã€äŊŽå‹•æ…‹å ´æ™¯įš„į•ĢčŗĒ。可čƒŊ與čŧƒčˆŠįš„čŖįŊŽä¸į›¸åŽšã€‚", "transcoding_threads": "åŸˇčĄŒįˇ’æ•¸é‡", - "transcoding_threads_description": "čŧƒéĢ˜įš„å€ŧ會加åŋĢᎍįĸŧ速åēĻīŧŒäŊ†æœƒæ¸›å°‘äŧ翜å™¨åœ¨åŸˇčĄŒéŽį¨‹ä¸­č™•ᐆå…ļäģ–äģģå‹™įš„įŠē間。此å€ŧ不應čļ…過 CPU æ ¸åŋƒæ•¸ã€‚設åޚį‚ē 0 可äģĨæœ€å¤§åŒ–åˆŠį”¨įŽ‡ã€‚", + "transcoding_threads_description": "čŧƒéĢ˜įš„æ•¸å€ŧ會加åŋĢᎍįĸŧ速åēĻīŧŒäŊ†åŸˇčĄŒæ™‚會äŊ”į”¨æ›´å¤šäŧ翜å™¨č™•ᐆå…ļäģ–äģģå‹™įš„æ•ˆčƒŊ。此數å€ŧ不應čļ…過 CPU æ ¸åŋƒæ•¸ã€‚設į‚ē 0 å¯æœ€å¤§åŒ–åˆŠį”¨įŽ‡ã€‚", "transcoding_tone_mapping": "色čĒŋ對映", "transcoding_tone_mapping_description": "在將 HDR åŊąį‰‡čŊ‰æ›į‚ē SDR 時īŧŒį›Ąé‡įļ­æŒåŽŸå§‹č§€æ„Ÿã€‚æ¯į¨Žæŧ”įŽ—æŗ•åœ¨č‰˛åŊŠã€į´°į¯€å’ŒäēŽåēĻæ–šéĸéƒŊæœ‰ä¸åŒįš„æŦŠčĄĄã€‚Hable äŋį•™į´°į¯€īŧŒMobius äŋį•™č‰˛åŊŠīŧŒReinhard äŋį•™äēŽåēĻ。", "transcoding_transcode_policy": "čŊ‰įĸŧį­–į•Ĩ", - "transcoding_transcode_policy_description": "åŊąį‰‡äŊ•æ™‚æ‡‰é€˛čĄŒčŊ‰įĸŧįš„į­–į•Ĩ。HDR åŊąį‰‡ä¸€åŽšæœƒčŊ‰įĸŧīŧˆé™¤éžåœį”¨čŊ‰įĸŧīŧ‰ã€‚", + "transcoding_transcode_policy_description": "åŊąį‰‡čŊ‰įĸŧį­–į•Ĩ。HDR åŊąį‰‡ä¸€åž‹æœƒé€˛čĄŒčŊ‰įĸŧīŧˆé™¤éžåœį”¨čŊ‰įĸŧ功čƒŊīŧ‰ã€‚", "transcoding_two_pass_encoding": "兊階æŽĩᎍįĸŧ", - "transcoding_two_pass_encoding_setting_description": "äŊŋį”¨å…ŠéšŽæŽĩᎍįĸŧ來į”ĸį”Ÿå“čŗĒ更äŊŗįš„ᎍįĸŧåŊąį‰‡ã€‚į•ļå•Ÿį”¨æœ€å¤§äŊå…ƒé€ŸįŽ‡æ™‚īŧˆH.264 和 HEVC åŋ…é ˆå•Ÿį”¨æ­¤é¸é …æ‰čƒŊ運äŊœīŧ‰īŧŒæ­¤æ¨ĄåŧæœƒäģĨ最大äŊå…ƒé€ŸįŽ‡äž†čĒŋ整äŊå…ƒé€ŸįŽ‡į¯„åœīŧŒä¸ĻåŋŊį•Ĩ CRF。對æ–ŧ VP9īŧŒåĻ‚æžœåœį”¨æœ€å¤§äŊå…ƒé€ŸįއīŧŒå¯äģĨäŊŋᔍ CRF。", + "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_number_of_days_description": "é …į›Žåœ¨åžƒåœžæĄļ中äŋį•™įš„夊數īŧŒé€žæœŸåžŒå°‡æ°¸äš…åˆĒ除", "trash_settings": "垃圞æĄļč¨­åŽš", "trash_settings_description": "įŽĄį†åžƒåœžæĄļč¨­åŽš", "unlink_all_oauth_accounts": "č§Ŗé™¤æ‰€æœ‰ OAuth å¸ŗč™Ÿįš„é€Ŗįĩ", "unlink_all_oauth_accounts_description": "圍過į§ģč‡ŗæ–°įš„æœå‹™æäž›č€…å‰īŧŒčĢ‹ä¸čρåŋ˜č¨˜čĻå…ˆč§Ŗé™¤æ‰€æœ‰čˆ‡ OAuth 叺æˆļįš„é€Ŗįĩã€‚", - "unlink_all_oauth_accounts_prompt": "您是åĻįĸēčĒčĻč§Ŗé™¤æ‰€æœ‰čˆ‡ OAuth 叺æˆļįš„é€ŖįĩīŧŸæ‰€æœ‰į›¸é—œįš„äŊŋᔍ者čēĢäģŊ會čĸĢ重設īŧŒä¸Ļ且不čƒŊčĸĢ還原。", + "unlink_all_oauth_accounts_prompt": "您įĸē厚čĻč§Ŗé™¤æ‰€æœ‰čˆ‡ OAuth å¸ŗč™Ÿįš„é€Ŗįĩå—ŽīŧŸé€™æœƒé‡č¨­æ¯äŊäŊŋį”¨č€…įš„ OAuth ID ä¸”į„Ąæŗ•åžŠåŽŸã€‚", "user_cleanup_job": "æ¸…į†äŊŋᔍ者", "user_delete_delay": "{user} įš„å¸ŗč™Ÿå’Œé …į›Žæœƒåœ¨ {delay, plural, one {# 夊} other {# 夊}} 垌永䚅åˆĒ除。", "user_delete_delay_settings": "åģļ垌åˆĒ除", - "user_delete_delay_settings_description": "č‡Ēį§ģ除垌čĩˇįŽ—įš„å¤Šæ•¸īŧŒé€žæœŸåžŒå°‡æ°¸äš…åˆĒ除äŊŋį”¨č€…å¸ŗč™Ÿčˆ‡åĒ’éĢ”ã€‚äŊŋᔍ者åˆĒ除äŊœæĨ­æœƒåœ¨æ¯æ—Ĩåˆå¤œåŸˇčĄŒīŧŒäģĨæĒĸæŸĨįŦĻ合åˆĒ除æĸäģļįš„å¸ŗč™Ÿã€‚æ­¤č¨­åŽšįš„čŽŠæ›´æœƒåœ¨ä¸‹ä¸€æŦĄåŸˇčĄŒæ™‚į”Ÿæ•ˆã€‚", - "user_delete_immediately": "{user} įš„å¸ŗč™Ÿčˆ‡åĒ’é̔將įĢ‹åŗæŽ’å…Ĩ永䚅åˆĒé™¤įš„äŊ‡åˆ—。", - "user_delete_immediately_checkbox": "įĢ‹åŗå°‡äŊŋį”¨č€…čˆ‡čŗ‡į”ĸ排å…Ĩ永䚅åˆĒ除äŊ‡åˆ—", + "user_delete_delay_settings_description": "č‡Ēį§ģ除垌čĩˇįŽ—įš„å¤Šæ•¸īŧŒé€žæœŸåžŒå°‡æ°¸äš…åˆĒ除äŊŋį”¨č€…å¸ŗč™Ÿčˆ‡é …į›Žã€‚äŊŋᔍ者åˆĒ除äŊœæĨ­æœƒåœ¨æ¯æ—Ĩåˆå¤œåŸˇčĄŒīŧŒäģĨæĒĸæŸĨįŦĻ合åˆĒ除æĸäģļįš„å¸ŗč™Ÿã€‚æ­¤č¨­åŽšįš„čŽŠæ›´å°‡åœ¨ä¸‹ä¸€æŦĄåŸˇčĄŒæ™‚į”Ÿæ•ˆã€‚", + "user_delete_immediately": "{user} įš„å¸ŗč™Ÿčˆ‡é …į›Žå°‡įĢ‹åŗæŽ’å…Ĩ永䚅åˆĒ除äŊ‡åˆ—。", + "user_delete_immediately_checkbox": "įĢ‹åŗå°‡äŊŋį”¨č€…čˆ‡é …į›ŽæŽ’å…Ĩ永䚅åˆĒ除äŊ‡åˆ—", "user_details": "äŊŋį”¨č€…čŠŗį´°čŗ‡č¨Š", "user_management": "äŊŋį”¨č€…įŽĄį†", "user_password_has_been_reset": "äŊŋᔍ者坆įĸŧåˇ˛é‡č¨­īŧš", @@ -438,10 +438,10 @@ "user_restore_scheduled_removal": "還原äŊŋᔍ者 - 預厚æ–ŧ {date, date, long} į§ģ除", "user_settings": "äŊŋį”¨č€…č¨­åŽš", "user_settings_description": "įŽĄį†äŊŋį”¨č€…č¨­åŽš", - "user_successfully_removed": "ᔍæˆļ{email}åˇ˛æˆåŠŸåˆ é™¤ã€‚", - "users_page_description": "įŽĄį†į”¨æˆļ頁éĸ", + "user_successfully_removed": "åˇ˛æˆåŠŸåˆĒ除äŊŋᔍ者 {email}。", + "users_page_description": "įŽĄį†äŊŋᔍ者頁éĸ", "version_check_enabled_description": "å•Ÿį”¨į‰ˆæœŦæĒĸæŸĨ", - "version_check_implications": "į‰ˆæœŦæĒĸæŸĨ功čƒŊæœƒåŽšæœŸčˆ‡ github.com 通訊", + "version_check_implications": "į‰ˆæœŦæĒĸæŸĨ功čƒŊäģ°čŗ´čˆ‡ github.com įš„åŽšæœŸé€šč¨Š", "version_check_settings": "į‰ˆæœŦæĒĸæŸĨ", "version_check_settings_description": "å•Ÿį”¨ / åœį”¨æ–°į‰ˆæœŦ通įŸĨ", "video_conversion_job": "åŊąį‰‡čŊ‰įĸŧ", @@ -449,23 +449,23 @@ }, "admin_email": "įŽĄį†å“Ąé›ģ子éƒĩäģļ", "admin_password": "įŽĄį†å“Ąå¯†įĸŧ", - "administration": "įŽĄį†", + "administration": "įŗģįĩąįŽĄį†", "advanced": "進階", "advanced_settings_clear_image_cache": "æ¸…é™¤åœ–į‰‡åŋĢ取", "advanced_settings_clear_image_cache_error": "æ¸…é™¤åœ–į‰‡åŋĢå–å¤ąæ•—", "advanced_settings_clear_image_cache_success": "成功清除{size}", "advanced_settings_enable_alternate_media_filter_subtitle": "äŊŋį”¨æ­¤é¸é …å¯åœ¨åŒæ­Ĩ時䞝å…ļäģ–æĸäģļį¯Šé¸åĒ’éĢ”ã€‚åƒ…åœ¨æ‡‰į”¨į¨‹åŧį„Ąæŗ•åĩæ¸Ŧåˆ°æ‰€æœ‰į›¸į°ŋ時再嘗čŠĻäŊŋį”¨ã€‚", "advanced_settings_enable_alternate_media_filter_title": "[å¯Ļ銗性] äŊŋᔍæ›ŋäģŖįš„čŖįŊŽį›¸į°ŋ同æ­Ĩį¯Šé¸å™¨", - "advanced_settings_log_level_title": "æ—ĨčĒŒį­‰į´šīŧš{level}", - "advanced_settings_prefer_remote_subtitle": "éƒ¨åˆ†čŖįŊŽåžžæœŦ抟åĒ’éĢ”åēĢčŧ‰å…Ĩį¸Žåœ–įš„é€ŸåēĻ非常æ…ĸã€‚å•Ÿį”¨æ­¤č¨­åŽšå¯æ”šį‚ēčŧ‰å…Ĩ遠įĢ¯åœ–į‰‡ã€‚", + "advanced_settings_log_level_title": "į´€éŒ„į­‰į´šīŧš{level}", + "advanced_settings_prefer_remote_subtitle": "éƒ¨åˆ†čŖįŊŽåžžæœŦæŠŸé …į›Žčŧ‰å…Ĩį¸Žåœ–įš„é€ŸåēĻ非常æ…ĸã€‚å•Ÿį”¨æ­¤č¨­åŽšå¯æ”šį‚ēčŧ‰å…Ĩ遠įĢ¯åœ–į‰‡ã€‚", "advanced_settings_prefer_remote_title": "偏åĨŊ遠į̝åŊąåƒ", "advanced_settings_proxy_headers_subtitle": "åŽšįžŠ Immich 在每æŦĄįļ˛čˇ¯čĢ‹æą‚æ™‚æ‡‰čŠ˛å‚ŗé€įš„äģŖį†æ¨™é ­", "advanced_settings_proxy_headers_title": "č‡ĒåŽšįžŠäģŖį†æ¨™é ­[å¯Ļ銗性]", - "advanced_settings_readonly_mode_subtitle": "é–‹å•Ÿå”¯čŽ€æ¨ĄåŧåžŒīŧŒį…§į‰‡åĒčƒŊį€čĻŊīŧŒåƒæ˜¯å¤šé¸åŊąåƒã€åˆ†äēĢ、投攞、åˆĒé™¤į­‰åŠŸčƒŊéƒŊ會關閉。可在ä¸ģį•Ģéĸ透過äŊŋį”¨č€…é ­åƒäž†é–‹å•Ÿ/é—œé–‰å”¯čŽ€æ¨Ąåŧ", + "advanced_settings_readonly_mode_subtitle": "å•Ÿį”¨å”¯čŽ€æ¨ĄåŧåžŒåƒ…čƒŊį€čĻŊᛏቇīŧŒå°‡åœį”¨å¤šé¸ã€åˆ†äēĢ、投攞及åˆĒé™¤į­‰åŠŸčƒŊ。可透過ä¸ģį•Ģéĸä¸Šįš„äŊŋᔍ者個äēē圖į¤ēå•Ÿį”¨æˆ–åœį”¨å”¯čŽ€æ¨Ąåŧ", "advanced_settings_readonly_mode_title": "å”¯čŽ€æ¨Ąåŧ", "advanced_settings_self_signed_ssl_subtitle": "į•Ĩ過äŧ翜å™¨į̝éģžįš„ SSL æ†‘č­‰éŠ—č­‰ã€‚č‡Ēį°Ŋæ†‘č­‰æ™‚åŋ…é ˆå•Ÿį”¨æ­¤č¨­åŽšã€‚", "advanced_settings_self_signed_ssl_title": "å…č¨ąč‡Ēį°Ŋįš„ SSL æ†‘č­‰[å¯Ļ銗性]", - "advanced_settings_sync_remote_deletions_subtitle": "į•ļ在įļ˛é įĢ¯åŸˇčĄŒåˆĒ除或還原操äŊœæ™‚īŧŒč‡Ēå‹•åœ¨æ­¤čŖįŊŽä¸ŠåˆĒé™¤æˆ–é‚„åŽŸčŠ˛åĒ’éĢ”", + "advanced_settings_sync_remote_deletions_subtitle": "į•ļ在įļ˛é įĢ¯åŸˇčĄŒåˆĒ除或還原操äŊœæ™‚īŧŒč‡Ēå‹•åœ¨æ­¤čŖįŊŽä¸ŠåˆĒé™¤æˆ–é‚„åŽŸčŠ˛é …į›Ž", "advanced_settings_sync_remote_deletions_title": "同æ­Ĩ遠į̝åˆĒ除 [å¯Ļ銗性]", "advanced_settings_tile_subtitle": "進階äŊŋį”¨č€…č¨­åŽš", "advanced_settings_troubleshooting_subtitle": "å•Ÿį”¨éĄå¤–åŠŸčƒŊäģĨé€˛čĄŒį–‘é›ŖæŽ’č§Ŗ", @@ -474,14 +474,14 @@ "age_year_months": "1 æ­˛īŧŒ{months, plural, one {# 個月} other {# 個月}}", "age_years": "{years, plural, other {# æ­˛}}", "album": "ᛏį°ŋ", - "album_added": "čĸĢ加å…Ĩåˆ°į›¸į°ŋ", - "album_added_notification_setting_description": "į•ļ我čĸĢ加å…Ĩå…ąäēĢᛏį°ŋ時īŧŒį”¨é›ģ子éƒĩäģļ通įŸĨ我", + "album_added": "厞加å…Ĩᛏį°ŋ", + "album_added_notification_setting_description": "į•ļ我čĸĢ加å…Ĩå…ąäēĢᛏį°ŋ時īŧŒé€éŽé›ģ子éƒĩäģļ通įŸĨ我", "album_cover_updated": "åˇ˛æ›´æ–°į›¸į°ŋ封éĸ", "album_delete_confirmation": "äŊ įĸē厚čρåˆĒ除ᛏį°ŋ {album} 嗎īŧŸ", - "album_delete_confirmation_description": "åĻ‚æžœæ­¤į›¸į°ŋ厞čĸĢ分äēĢīŧŒå…ļäģ–äŊŋį”¨č€…å°‡į„Ąæŗ•å†å­˜å–ã€‚", + "album_delete_confirmation_description": "åĻ‚æžœæ­¤į›¸į°ŋ厞čĸĢå…ąäēĢīŧŒå…ļäģ–äŊŋį”¨č€…å°‡į„Ąæŗ•å†å­˜å–ã€‚", "album_deleted": "ᛏį°ŋ厞åˆĒ除", "album_info_card_backup_album_excluded": "åˇ˛æŽ’é™¤", - "album_info_card_backup_album_included": "厞遏䏭", + "album_info_card_backup_album_included": "åˇ˛åŒ…åĢ", "album_info_updated": "åˇ˛æ›´æ–°į›¸į°ŋčŗ‡č¨Š", "album_leave": "é›ĸ開ᛏį°ŋīŧŸ", "album_leave_confirmation": "您įĸē厚čρé›ĸ開 {album} 嗎īŧŸ", @@ -490,12 +490,12 @@ "album_remove_user": "į§ģ除äŊŋᔍ者īŧŸ", "album_remove_user_confirmation": "įĸē厚čρį§ģ除 {user} 嗎īŧŸ", "album_search_not_found": "扞不到įŦĻ合搜尋æĸäģļįš„į›¸į°ŋ", - "album_selected": "åˇ˛é¸æ“‡į›¸å†Œ", + "album_selected": "åˇ˛é¸å–į›¸į°ŋ", "album_share_no_users": "įœ‹äž†æ‚¨čˆ‡æ‰€æœ‰äŊŋį”¨č€…å…ąäēĢäē†é€™æœŦᛏį°ŋīŧŒæˆ–æ˛’æœ‰å…ļäģ–äŊŋį”¨č€…å¯äž›åˆ†äēĢ。", "album_summary": "ᛏį°ŋ摘čρ", "album_updated": "æ›´æ–°į›¸į°ŋ時", "album_updated_setting_description": "į•ļå…ąäēĢᛏį°ŋæœ‰æ–°é …į›Žæ™‚į”¨é›ģ子éƒĩäģļ通įŸĨ我", - "album_upload_assets": "åžžæ‚¨įš„č¨ˆįŽ—æŠŸä¸Šå‚ŗæ–‡äģļä¸ĻæˇģåŠ åˆ°į›¸å†Š", + "album_upload_assets": "åžžæ‚¨įš„é›ģč…Ļä¸Šå‚ŗæĒ”æĄˆä¸Ļ加å…Ĩᛏį°ŋ", "album_user_left": "é›ĸ開 {album}", "album_user_removed": "į§ģ除 {user}", "album_viewer_appbar_delete_confirm": "您įĸē厚čĻåžžå¸ŗč™Ÿä¸­åˆĒé™¤æ­¤į›¸į°ŋ嗎īŧŸ", @@ -506,18 +506,18 @@ "album_viewer_appbar_share_leave": "é›ĸ開ᛏį°ŋ", "album_viewer_appbar_share_to": "分äēĢįĩĻ", "album_viewer_page_share_add_users": "邀čĢ‹å…ļäģ–äēē", - "album_with_link_access": "äģģäŊ•æ“æœ‰é€Ŗįĩįš„äēēéƒŊčƒŊæĒĸčĻ–æ­¤į›¸į°ŋä¸­įš„į…§į‰‡čˆ‡äēēį‰Šã€‚", + "album_with_link_access": "äģģäŊ•æ“æœ‰é€Ŗįĩįš„äēēįš†å¯æĒĸčĻ–æ­¤į›¸į°ŋä¸­įš„į›¸į‰‡čˆ‡äēēį‰Šã€‚", "albums": "ᛏį°ŋ", "albums_count": "{count, plural, one {{count, number} 個ᛏį°ŋ} other {{count, number} 個ᛏį°ŋ}}", "albums_default_sort_order": "預荭ᛏį°ŋ排åē", "albums_default_sort_order_description": "åģēįĢ‹æ–°į›¸į°ŋ時čĻåˆå§‹åŒ–é …į›ŽæŽ’åēæ–šåŧã€‚", - "albums_feature_description": "一įŗģ列可äģĨ分äēĢįĩĻå…ļäģ–äŊŋį”¨č€…įš„é …į›Žã€‚", + "albums_feature_description": "å¯å…ąäēĢįĩĻå…ļäģ–äŊŋį”¨č€…įš„é …į›Žé›†åˆã€‚", "albums_on_device_count": "æ­¤čŖįŊŽæœ‰ ({count}) 個ᛏį°ŋ", - "albums_selected": "{count, plural, one {# å€‹åˇ˛é¸æ“‡å°ˆčŧ¯} other {# å€‹åˇ˛é¸æ“‡å°ˆčŧ¯}}", + "albums_selected": "{count, plural, one {åˇ˛é¸å– # æœŦᛏį°ŋ} other {åˇ˛é¸å– # æœŦᛏį°ŋ}}", "all": "全部", "all_albums": "æ‰€æœ‰į›¸į°ŋ", "all_people": "所有äēēį‰Š", - "all_photos": "æ‰€æœ‰į…§į‰‡", + "all_photos": "æ‰€æœ‰į›¸į‰‡", "all_videos": "所有åŊąį‰‡", "allow_dark_mode": "å…č¨ąæˇąč‰˛æ¨Ąåŧ", "allow_edits": "å…č¨ąįˇ¨čŧ¯", @@ -526,27 +526,27 @@ "allowed": "å…č¨ą", "alt_text_qr_code": "QR code åœ–į‰‡", "always_keep": "一型äŋį•™", - "always_keep_photos_hint": "æ‰€æœ‰įš„į…§į‰‡å°‡æœƒčĸĢäŋį•™åœ¨æ­¤čŖįŊŽä¸Šã€‚", - "always_keep_videos_hint": "æ‰€æœ‰įš„åŊąį‰‡å°‡æœƒčĸĢäŋį•™åœ¨æ­¤čŖįŊŽä¸Šã€‚", + "always_keep_photos_hint": "「釋攞įŠē間」功čƒŊæœƒå°‡æ‰€æœ‰į›¸į‰‡äŋį•™åœ¨æ­¤čŖįŊŽä¸Šã€‚", + "always_keep_videos_hint": "「釋攞įŠē間」功čƒŊ會將所有åŊąį‰‡äŋį•™åœ¨æ­¤čŖįŊŽä¸Šã€‚", "anti_clockwise": "逆時針", "api_key": "API 金鑰", - "api_key_description": "æ­¤é‡‘é‘°åƒ…éĄ¯į¤ē一æŦĄã€‚čĢ‹åœ¨é—œé–‰å‰č¤‡čŖŊ厃。", - "api_key_empty": "æ‚¨įš„ API é‡‘é‘°åį¨ąä¸čƒŊį‚ēįŠēå€ŧ", + "api_key_description": "æ­¤é‡‘é‘°åƒ…æœƒéĄ¯į¤ē一æŦĄã€‚關閉čĻ–įĒ—å‰č̋務åŋ…å…ˆč¤‡čŖŊ金鑰。", + "api_key_empty": "API é‡‘é‘°åį¨ąä¸åž—į‚ēįŠēį™Ŋ", "api_keys": "API 金鑰", - "app_architecture_variant": "變éĢ”īŧˆæžļ構īŧ‰", + "app_architecture_variant": "čŽŠåŒ–į‰ˆæœŦīŧˆæžļ構īŧ‰", "app_bar_signout_dialog_content": "您įĸē厚čρį™ģå‡ē嗎īŧŸ", "app_bar_signout_dialog_ok": "是", "app_bar_signout_dialog_title": "į™ģå‡ē", - "app_download_links": "æ‡‰į”¨ä¸‹čŧ‰é€Ŗįĩ", + "app_download_links": "App 下čŧ‰é€Ŗįĩ", "app_settings": "æ‡‰į”¨į¨‹åŧč¨­åޚ", - "app_stores": "æ‡‰į”¨å•†åē—", - "app_update_available": "æ‡‰į”¨į¨‹åēæ›´æ–°å¯į”¨", + "app_stores": "æ‡‰į”¨į¨‹åŧå•†åē—", + "app_update_available": "åˇ˛æœ‰æ‡‰į”¨į¨‹åŧæ›´æ–°", "appears_in": "å‡ēįžæ–ŧ", - "apply_count": "æ‡‰į”¨ ({count, number})", + "apply_count": "åĨ—ᔍ ({count, number})", "archive": "封存", - "archive_action_prompt": "厞將 ({count}) 個加å…Ĩé€˛å°å­˜", - "archive_or_unarchive_photo": "封存或取æļˆå°å­˜į…§į‰‡", - "archive_page_no_archived_assets": "æœĒ扞到封存åĒ’éĢ”", + "archive_action_prompt": "厞將 {count} å€‹é …į›ŽåŠ å…Ĩ封存", + "archive_or_unarchive_photo": "封存或取æļˆå°å­˜į›¸į‰‡", + "archive_page_no_archived_assets": "æ‰žä¸åˆ°å°å­˜é …į›Ž", "archive_page_title": "封存 ({count})", "archive_size": "封存大小", "archive_size_description": "č¨­åŽščρ䏋čŧ‰įš„封存æĒ”æĄˆå¤§å° (å–ŽäŊīŧšGiB)", @@ -554,60 +554,60 @@ "archived_count": "{count, plural, other {厞封存 # å€‹é …į›Ž}}", "are_these_the_same_person": "同一äŊäēēį‰ŠīŧŸ", "are_you_sure_to_do_this": "您įĸē厚嗎īŧŸ", - "array_field_not_fully_supported": "數įĩ„æŦ„äŊéœ€čĻæ‰‹å‹•JSONᎍčŧ¯", - "asset_action_delete_err_read_only": "į•ĨéŽį„Ąæŗ•åˆĒé™¤å”¯čŽ€é …į›Ž", - "asset_action_share_err_offline": "į•ĨéŽį„Ąæŗ•å–åž—įš„é›ĸįˇšé …į›Ž", - "asset_added_to_album": "厞åģēį̋ᛏį°ŋ", + "array_field_not_fully_supported": "é™Ŗåˆ—æŦ„äŊéœ€čĻæ‰‹å‹•įˇ¨čŧ¯ JSON", + "asset_action_delete_err_read_only": "å”¯čŽ€é …į›Žį„Ąæŗ•åˆĒ除īŧŒåˇ˛į•Ĩ過", + "asset_action_share_err_offline": "į„Ąæŗ•å–åž—é›ĸįˇšé …į›ŽīŧŒåˇ˛į•Ĩ過", + "asset_added_to_album": "åˇ˛æ–°åĸžč‡ŗį›¸į°ŋ", "asset_adding_to_album": "新åĸžåˆ°į›¸į°ŋâ€Ļ", - "asset_created": "čŗ‡į”ĸ厞å‰ĩåģē", - "asset_description_updated": "åĒ’éĢ”æčŋ°åˇ˛æ›´æ–°", - "asset_filename_is_offline": "åĒ’éĢ” {filename} 厞é›ĸ᎚", - "asset_has_unassigned_faces": "åĒ’éĢ”æœ‰æœĒåˆ†é…įš„č‡‰å­”", + "asset_created": "é …į›Žåˇ˛åģēįĢ‹", + "asset_description_updated": "é …į›ŽčĒĒæ˜Žåˇ˛æ›´æ–°", + "asset_filename_is_offline": "é …į›Ž {filename} 厞é›ĸ᎚", + "asset_has_unassigned_faces": "é …į›Žæœ‰æœĒæŒ‡æ´žįš„č‡‰å­”", "asset_hashing": "æ­Ŗåœ¨č¨ˆįŽ—é›œæšŠâ€Ļ", "asset_list_group_by_sub_title": "åˆ†éĄžæ–šåŧ", "asset_list_layout_settings_dynamic_layout_title": "å‹•æ…‹į‰ˆéĸ", "asset_list_layout_settings_group_automatically": "č‡Ē動", - "asset_list_layout_settings_group_by": "åĒ’éĢ”åˆ†éĄžæ–šåŧ", + "asset_list_layout_settings_group_by": "é …į›Žåˆ†éĄžæ–šåŧ", "asset_list_layout_settings_group_by_month_day": "月äģŊ和æ—Ĩ期", "asset_list_layout_sub_title": "į‰ˆéĸ", "asset_list_settings_subtitle": "ᛏቇæ ŧį‹€į‰ˆéĸč¨­åŽš", "asset_list_settings_title": "ᛏቇæ ŧį‹€æĒĸčĻ–", "asset_not_found_on_device_android": "į„Ąæŗ•åœ¨čŖįŊŽä¸Šæ‰žåˆ°é …į›Ž", - "asset_not_found_on_device_ios": "į„Ąæŗ•åœ¨čŖįŊŽä¸Šæ‰žåˆ°é …į›Žã€‚iCloud ä¸Šįš„é …į›Žå¯čƒŊ因æĒ”æĄˆæå¤ąį„Ąæŗ•æŸĨ閱", - "asset_not_found_on_icloud": "é …į›Žä¸å­˜åœ¨æ–ŧ在iCloudã€‚é …į›Žæœ‰æŠŸæœƒå› æĒ”æĄˆææ¯€č€Œį„Ąæŗ•æĒĸ閱", - "asset_offline": "åĒ’éĢ”é›ĸ᎚", - "asset_offline_description": "此外部åĒ’éĢ”åˇ˛į„Ąæŗ•åœ¨įŖįĸŸä¸­æ‰žåˆ°ã€‚č̋聝įĩĄæ‚¨įš„ Immich įŽĄį†å“ĄäģĨ取垗協劊。", - "asset_restored_successfully": "åĒ’éĢ”åžŠåŽŸæˆåŠŸ", + "asset_not_found_on_device_ios": "į„Ąæŗ•åœ¨čŖįŊŽä¸Šæ‰žåˆ°é …į›Žã€‚iCloud ä¸Šįš„é …į›Žå¯čƒŊ因æĒ”æĄˆææ¯€č€Œį„Ąæŗ•æŸĨ閱", + "asset_not_found_on_icloud": "iCloud ä¸Šæ‰žä¸åˆ°é …į›Žã€‚čŠ˛é …į›Žå¯čƒŊ因æĒ”æĄˆæ¯€æč€Œį„Ąæŗ•å­˜å–", + "asset_offline": "é …į›Žé›ĸ᎚", + "asset_offline_description": "æ­¤å¤–éƒ¨é …į›Žåˇ˛į„Ąæŗ•åœ¨įŖįĸŸä¸Šæ‰žåˆ°ã€‚č̋聝įĩĄæ‚¨įš„ Immich įŽĄį†å“ĄäģĨ取垗協劊。", + "asset_restored_successfully": "é …į›Žé‚„åŽŸæˆåŠŸ", "asset_skipped": "åˇ˛čˇŗéŽ", "asset_skipped_in_trash": "åˇ˛åœ¨åžƒåœžæĄļ", - "asset_trashed": "čŗ‡į”ĸčĸĢä¸ŸæŖ„", - "asset_troubleshoot": "čŗ‡į”ĸ故障排除", + "asset_trashed": "é …į›Žåˇ˛į§ģč‡ŗåžƒåœžæĄļ", + "asset_troubleshoot": "é …į›Žæ•…éšœæŽ’é™¤", "asset_uploaded": "åˇ˛ä¸Šå‚ŗ", "asset_uploading": "ä¸Šå‚ŗä¸­â€Ļ", "asset_viewer_settings_subtitle": "įŽĄį†æ‚¨įš„åĒ’éĢ”åēĢæĒĸčĻ–å™¨č¨­åŽš", - "asset_viewer_settings_title": "åĒ’éĢ”æĒĸčϖ噍", - "assets": "åĒ’éĢ”", - "assets_added_count": "åˇ˛æ–°åĸž {count, plural, one {# 個åĒ’éĢ”} other {# 個åĒ’éĢ”}}", - "assets_added_to_album_count": "厞將 {count, plural, one {# 個åĒ’éĢ”} other {# 個åĒ’éĢ”}}加å…Ĩᛏį°ŋ", - "assets_added_to_albums_count": "åˇ˛æ–°åĸž {assetTotal, plural, one {# 個} other {# 個}}é …į›Žåˆ° {albumTotal, plural, one {# 個} other {# 個}}ᛏį°ŋ中", - "assets_cannot_be_added_to_album_count": "į„Ąæŗ•å°‡ {count, plural, one {åĒ’éĢ”} other {åĒ’éĢ”}} 加å…Ĩ臺ᛏį°ŋ", - "assets_cannot_be_added_to_albums": "{count, plural, one {個} other {個}}é …į›Žį„Ąæŗ•čĸĢ加å…Ĩᛏį°ŋ", - "assets_count": "{count, plural, one {# 個åĒ’éĢ”} other {# 個åĒ’éĢ”}}", - "assets_deleted_permanently": "{count} 個åĒ’é̔厞čĸĢæ°¸äš…åˆĒ除", - "assets_deleted_permanently_from_server": "åˇ˛åžž Immich äŧ翜å™¨ä¸­æ°¸äš…į§ģ除 {count} 個åĒ’éĢ”", + "asset_viewer_settings_title": "é …į›ŽæĒĸčϖ噍", + "assets": "é …į›Ž", + "assets_added_count": "åˇ˛æ–°åĸž {count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}}", + "assets_added_to_album_count": "厞將 {count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}}加å…Ĩ臺ᛏį°ŋ", + "assets_added_to_albums_count": "厞將 {assetTotal, plural, other {# å€‹é …į›Ž}} 新åĸžč‡ŗ {albumTotal, plural, other {# æœŦᛏį°ŋ}}", + "assets_cannot_be_added_to_album_count": "į„Ąæŗ•å°‡ {count, plural, one {é …į›Ž} other {é …į›Ž}} 加å…Ĩ臺ᛏį°ŋ", + "assets_cannot_be_added_to_albums": "į„Ąæŗ•å°‡ {count, plural, other {# å€‹é …į›Ž}} 加å…ĨäģģäŊ•ᛏį°ŋ", + "assets_count": "{count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}}", + "assets_deleted_permanently": "åˇ˛æ°¸äš…åˆĒ除 {count} å€‹é …į›Ž", + "assets_deleted_permanently_from_server": "åˇ˛åžž Immich äŧ翜å™¨ä¸­æ°¸äš…į§ģ除 {count} å€‹é …į›Ž", "assets_downloaded_failed": "{count, plural, one {厞䏋čŧ‰ # 個æĒ”æĄˆ - {error} 個æĒ”æĄˆå¤ąæ•—} other {厞䏋čŧ‰ # 個æĒ”æĄˆ - {error} 個æĒ”æĄˆå¤ąæ•—}}", "assets_downloaded_successfully": "{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_removed_permanently_from_device": "åˇ˛åžžæ‚¨įš„čŖįŊŽæ°¸äš…į§ģ除 {count} 個åĒ’éĢ”", - "assets_restore_confirmation": "您įĸē厚čĻé‚„åŽŸæ‰€æœ‰åžƒåœžæĄļä¸­įš„åĒ’éĢ”å—ŽīŧŸæ­¤æ“äŊœį„Ąæŗ•垊原īŧčĢ‹æŗ¨æ„īŧŒäģģäŊ•é›ĸ᎚åĒ’éĢ”éƒŊį„Ąæŗ•é€éŽæ­¤æ–šåŧé‚„原。", - "assets_restored_count": "åˇ˛é‚„åŽŸ {count, plural, one {# 個åĒ’éĢ”} other {# 個åĒ’éĢ”}}", - "assets_restored_successfully": "åˇ˛æˆåŠŸé‚„åŽŸ {count} 個åĒ’éĢ”", - "assets_trashed": "厞將 {count} 個åĒ’éĢ”į§ģč‡ŗåžƒåœžæĄļ", - "assets_trashed_count": "厞將 {count, plural, one {# 個åĒ’éĢ”} other {# 個åĒ’éĢ”}} į§ģč‡ŗåžƒåœžæĄļ", - "assets_trashed_from_server": "åˇ˛åžž Immich äŧ翜å™¨å°‡ {count} 個åĒ’éĢ”į§ģč‡ŗåžƒåœžæĄļ", - "assets_were_part_of_album_count": "{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_removed_permanently_from_device": "åˇ˛åžžæ‚¨įš„čŖįŊŽæ°¸äš…į§ģ除 {count} å€‹é …į›Ž", + "assets_restore_confirmation": "您įĸē厚čĻé‚„åŽŸæ‰€æœ‰åžƒåœžæĄļä¸­įš„é …į›Žå—ŽīŧŸæ­¤æ“äŊœį„Ąæŗ•垊原īŧčĢ‹æŗ¨æ„īŧŒäģģäŊ•é›ĸįˇšé …į›ŽéƒŊį„Ąæŗ•é€éŽæ­¤æ–šåŧé‚„原。", + "assets_restored_count": "åˇ˛é‚„åŽŸ {count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}}", + "assets_restored_successfully": "åˇ˛æˆåŠŸé‚„åŽŸ {count} å€‹é …į›Ž", + "assets_trashed": "厞將 {count} å€‹é …į›Žį§ģč‡ŗåžƒåœžæĄļ", + "assets_trashed_count": "厞將 {count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}}į§ģč‡ŗåžƒåœžæĄļ", + "assets_trashed_from_server": "åˇ˛åžž Immich äŧ翜å™¨å°‡ {count} å€‹é …į›Žį§ģč‡ŗåžƒåœžæĄļ", + "assets_were_part_of_album_count": "{count, plural, one {čŠ˛é …į›Žåˇ˛} other {這äē›é …į›Žåˇ˛}}åœ¨į›¸į°ŋ中", "assets_were_part_of_albums_count": "{count, plural, one {個} other {個}}é …į›Žåˇ˛čĸĢå„˛å­˜åœ¨į›¸į°ŋ中", "authorized_devices": "åˇ˛æŽˆæŦŠčŖįŊŽ", "automatic_endpoint_switching_subtitle": "į•ļå¯į”¨æ™‚īŧŒé€éŽæŒ‡åŽšįš„ Wi-Fi 在æœŦæŠŸé€ŖįˇšīŧŒå…ļäģ–æƒ…æŗå‰‡äŊŋᔍæ›ŋäģŖé€Ŗįˇš", @@ -622,19 +622,19 @@ "backup": "備äģŊ", "backup_album_selection_page_albums_device": "čŖįŊŽä¸Šįš„ᛏį°ŋīŧˆ{count}īŧ‰", "backup_album_selection_page_albums_tap": "éģžä¸€ä¸‹äģĨ選取īŧŒéģžå…Šä¸‹äģĨ排除", - "backup_album_selection_page_assets_scatter": "åĒ’éĢ”å¯äģĨåˆ†æ•Ŗåœ¨å¤šå€‹į›¸į°ŋ中īŧŒå› æ­¤åœ¨å‚™äģŊéŽį¨‹ä¸­å¯äģĨé¸æ“‡į´å…Ĩæˆ–æŽ’é™¤į›¸į°ŋ。", + "backup_album_selection_page_assets_scatter": "é …į›Žå¯äģĨåˆ†æ•Ŗåœ¨å¤šå€‹į›¸į°ŋ中īŧŒå› æ­¤åœ¨å‚™äģŊéŽį¨‹ä¸­å¯äģĨé¸æ“‡į´å…Ĩæˆ–æŽ’é™¤į›¸į°ŋ。", "backup_album_selection_page_select_albums": "é¸å–į›¸į°ŋ", "backup_album_selection_page_selection_info": "é¸å–čŗ‡č¨Š", - "backup_album_selection_page_total_assets": "į¸Ŋ不重複åĒ’éĢ”æ•¸", + "backup_album_selection_page_total_assets": "į¸Ŋä¸é‡č¤‡é …į›Žæ•¸", "backup_albums_sync": "備äģŊᛏį°ŋ同æ­Ĩ", "backup_all": "全部", - "backup_background_service_backup_failed_message": "備äģŊåĒ’éĢ”å¤ąæ•—ã€‚æ­Ŗåœ¨é‡čŠĻâ€Ļ", - "backup_background_service_complete_notification": "čŗ‡į”ĸ備äģŊ厌成", + "backup_background_service_backup_failed_message": "備äģŊé …į›Žå¤ąæ•—ã€‚æ­Ŗåœ¨é‡čŠĻâ€Ļ", + "backup_background_service_complete_notification": "é …į›Žå‚™äģŊ厌成", "backup_background_service_connection_failed_message": "é€Ŗįˇšč‡ŗäŧ翜å™¨å¤ąæ•—ã€‚æ­Ŗåœ¨é‡čŠĻâ€Ļ", "backup_background_service_current_upload_notification": "æ­Ŗåœ¨ä¸Šå‚ŗ {filename}", - "backup_background_service_default_notification": "æ­Ŗåœ¨æĒĸæŸĨ新åĒ’éĢ”â€Ļ", + "backup_background_service_default_notification": "æ­Ŗåœ¨æĒĸæŸĨæ–°é …į›Žâ€Ļ", "backup_background_service_error_title": "備äģŊ錯čǤ", - "backup_background_service_in_progress_notification": "æ­Ŗåœ¨å‚™äģŊæ‚¨įš„åĒ’éĢ”â€Ļ", + "backup_background_service_in_progress_notification": "æ­Ŗåœ¨å‚™äģŊæ‚¨įš„é …į›Žâ€Ļ", "backup_background_service_upload_failure_notification": "{filename} ä¸Šå‚ŗå¤ąæ•—", "backup_controller_page_albums": "備äģŊᛏį°ŋ", "backup_controller_page_background_app_refresh_disabled_content": "čĢ‹åœ¨ã€Œč¨­åŽšã€>「一čˆŦ」>ã€ŒčƒŒæ™¯ App é‡æ–°æ•´į†ã€ä¸­å•Ÿį”¨īŧŒäģĨäŊŋį”¨čƒŒæ™¯å‚™äģŊ功čƒŊ。", @@ -646,18 +646,18 @@ "backup_controller_page_background_battery_info_title": "é›ģæą æœ€äŊŗåŒ–", "backup_controller_page_background_charging": "僅在充é›ģ時", "backup_controller_page_background_configure_error": "čƒŒæ™¯æœå‹™č¨­åŽšå¤ąæ•—", - "backup_controller_page_background_delay": "新åĒ’é̔備äģŊåģļ遲īŧš{duration}", - "backup_controller_page_background_description": "é–‹å•ŸčƒŒæ™¯æœå‹™īŧŒåŗå¯åœ¨ä¸éœ€é–‹å•Ÿ App įš„æƒ…æŗä¸‹īŧŒč‡Ē動備äģŊ所有新åĒ’éĢ”", + "backup_controller_page_background_delay": "æ–°é …į›Žå‚™äģŊåģļ遲īŧš{duration}", + "backup_controller_page_background_description": "é–‹å•ŸčƒŒæ™¯æœå‹™īŧŒåŗå¯åœ¨ä¸éœ€é–‹å•Ÿ App įš„æƒ…æŗä¸‹īŧŒč‡Ē動備äģŊæ‰€æœ‰æ–°é …į›Ž", "backup_controller_page_background_is_off": "čƒŒæ™¯č‡Ē動備äģŊåˇ˛é—œé–‰", "backup_controller_page_background_is_on": "čƒŒæ™¯č‡Ē動備äģŊåˇ˛é–‹å•Ÿ", "backup_controller_page_background_turn_off": "é—œé–‰čƒŒæ™¯æœå‹™", "backup_controller_page_background_turn_on": "é–‹å•ŸčƒŒæ™¯æœå‹™", "backup_controller_page_background_wifi": "僅äŊŋᔍ Wi-Fi", "backup_controller_page_backup": "備äģŊ", - "backup_controller_page_backup_selected": "厞遏䏭īŧš ", - "backup_controller_page_backup_sub": "厞備äģŊįš„į…§į‰‡å’ŒåŊąį‰‡", + "backup_controller_page_backup_selected": "åˇ˛é¸å–īŧš ", + "backup_controller_page_backup_sub": "厞備äģŊįš„į›¸į‰‡čˆ‡åŊąį‰‡", "backup_controller_page_created": "åģēįĢ‹æ™‚é–“īŧš{date}", - "backup_controller_page_desc_backup": "開啟前č‡ē備äģŊīŧŒåœ¨é–‹å•Ÿ App 時č‡Ē動將新åĒ’éĢ”ä¸Šå‚ŗč‡ŗäŧ翜å™¨ã€‚", + "backup_controller_page_desc_backup": "開啟前景備äģŊīŧŒåœ¨é–‹å•Ÿ App 時č‡Ēå‹•å°‡æ–°é …į›Žä¸Šå‚ŗč‡ŗäŧ翜å™¨ã€‚", "backup_controller_page_excluded": "åˇ˛æŽ’é™¤īŧš ", "backup_controller_page_failed": "å¤ąæ•—īŧˆ{count}īŧ‰", "backup_controller_page_filename": "æĒ”æĄˆåį¨ąīŧš{filename} [{size}]", @@ -665,20 +665,20 @@ "backup_controller_page_info": "備äģŊčŗ‡č¨Š", "backup_controller_page_none_selected": "æœĒ選取äģģäŊ•é …į›Ž", "backup_controller_page_remainder": "削餘", - "backup_controller_page_remainder_sub": "é¸å–é …į›Žä¸­å°šæœĒ備äģŊįš„į…§į‰‡čˆ‡åŊąį‰‡", + "backup_controller_page_remainder_sub": "é¸å–é …į›Žä¸­å°šæœĒ備äģŊįš„į›¸į‰‡čˆ‡åŊąį‰‡", "backup_controller_page_server_storage": "äŧ翜å™¨å„˛å­˜įŠē間", "backup_controller_page_start_backup": "開始備äģŊ", "backup_controller_page_status_off": "前č‡ēč‡Ē動備äģŊåˇ˛é—œé–‰", "backup_controller_page_status_on": "前č‡ēč‡Ē動備äģŊåˇ˛é–‹å•Ÿ", "backup_controller_page_storage_format": "{used} / {total} 厞äŊŋᔍ", "backup_controller_page_to_backup": "čρ備äģŊįš„į›¸į°ŋ", - "backup_controller_page_total_sub": "åˇ˛é¸å–į›¸į°ŋä¸­įš„æ‰€æœ‰ä¸é‡č¤‡įš„į…§į‰‡čˆ‡åŊąį‰‡", + "backup_controller_page_total_sub": "åˇ˛é¸å–į›¸į°ŋä¸­įš„æ‰€æœ‰ä¸é‡č¤‡įš„į›¸į‰‡čˆ‡åŊąį‰‡", "backup_controller_page_turn_off": "關閉前č‡ē備äģŊ", "backup_controller_page_turn_on": "開啟前č‡ē備äģŊ", "backup_controller_page_uploading_file_info": "ä¸Šå‚ŗä¸­įš„æĒ”æĄˆčŗ‡č¨Š", "backup_err_only_album": "不čƒŊį§ģé™¤å”¯ä¸€įš„į›¸į°ŋ", "backup_error_sync_failed": "同æ­Ĩå¤ąæ•—īŧŒį„Ąæŗ•處ᐆ備äģŊ。", - "backup_info_card_assets": "個åĒ’éĢ”", + "backup_info_card_assets": "å€‹é …į›Ž", "backup_manual_cancelled": "åˇ˛å–æļˆ", "backup_manual_in_progress": "ä¸Šå‚ŗæ­Ŗåœ¨é€˛čĄŒä¸­īŧŒčĢ‹į¨åžŒå†čŠĻ", "backup_manual_success": "成功", @@ -690,23 +690,23 @@ "backup_upload_details_page_more_details": "éģžæ“ŠæŸĨįœ‹æ›´å¤ščŠŗį´°čŗ‡č¨Š", "backward": "į”ąčˆŠč‡ŗæ–°", "biometric_auth_enabled": "į”Ÿį‰Ščž¨č­˜éŠ—č­‰åˇ˛å•Ÿį”¨", - "biometric_locked_out": "æ‚¨åˇ˛čĸĢéŽ–åŽšį„Ąæŗ•äŊŋį”¨į”Ÿį‰Ščž¨č­˜éŠ—č­‰", + "biometric_locked_out": "į”Ÿį‰Ščž¨č­˜éŠ—č­‰åˇ˛čĸĢ鎖厚", "biometric_no_options": "æ˛’æœ‰į”Ÿį‰Ščž¨č­˜é¸é …å¯į”¨", - "biometric_not_available": "æ­¤čŖįŊŽä¸Šį„Ąæŗ•äŊŋį”¨į”Ÿį‰Ščž¨č­˜éŠ—č­‰", + "biometric_not_available": "æ­¤čŖįŊŽä¸æ”¯æ´į”Ÿį‰Ščž¨č­˜éŠ—č­‰", "birthdate_saved": "å‡ēį”Ÿæ—ĨæœŸå„˛å­˜æˆåŠŸ", - "birthdate_set_description": "å‡ēį”Ÿæ—ĨæœŸį”¨æ–ŧč¨ˆįŽ—æ­¤äēēåœ¨į…§į‰‡æ‹æ”æ™‚įš„åš´éŊĄã€‚", + "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", "cache_settings_clear_cache_button": "清除åŋĢ取", "cache_settings_clear_cache_button_title": "清除 App įš„åŋĢ取。此動äŊœæœƒåœ¨åŋĢ取重新åģēįĢ‹å‰īŧŒéĄ¯č‘—åŊąéŸŋ App įš„æ•ˆčƒŊ。", "cache_settings_duplicated_assets_clear_button": "清除", - "cache_settings_duplicated_assets_subtitle": "čĸĢæ‡‰į”¨į¨‹åŧåŠ å…ĨåŋŊį•Ĩæ¸…å–Žįš„į…§į‰‡čˆ‡åŊąį‰‡", + "cache_settings_duplicated_assets_subtitle": "čĸĢæ‡‰į”¨į¨‹åŧåŋŊį•Ĩæ¸…å–Žä¸­įš„į›¸į‰‡čˆ‡åŊąį‰‡", "cache_settings_duplicated_assets_title": "é‡č¤‡é …į›Žīŧˆ{count}īŧ‰", "cache_settings_statistics_album": "åĒ’éĢ”åēĢį¸Žåœ–", "cache_settings_statistics_full": "åŽŒæ•´åœ–į‰‡", @@ -735,42 +735,42 @@ "change_expiration_time": "čŽŠæ›´åˆ°æœŸæ™‚é–“", "change_location": "čŽŠæ›´äŊįŊŽ", "change_name": "čŽŠæ›´åį¨ą", - "change_name_successfully": "čŽŠæ›´åį¨ąæˆåŠŸ", + "change_name_successfully": "åį¨ąčŽŠæ›´æˆåŠŸ", "change_password": "čŽŠæ›´å¯†įĸŧ", "change_password_description": "這是您éĻ–æŦĄį™ģå…ĨįŗģįĩąīŧŒæˆ–æ˜¯åˇ˛æ”ļåˆ°čŽŠæ›´å¯†įĸŧįš„čĢ‹æą‚ã€‚čĢ‹åœ¨ä¸‹æ–ščŧ¸å…Ĩ新密įĸŧ。", "change_password_form_confirm_password": "įĸēčĒå¯†įĸŧ", "change_password_form_description": "您åĨŊ {name}īŧŒ\n\n這是您éĻ–æŦĄį™ģå…ĨįŗģįĩąīŧŒæˆ–æ˜¯åˇ˛æ”ļåˆ°čŽŠæ›´å¯†įĸŧįš„čĢ‹æą‚ã€‚čĢ‹åœ¨ä¸‹æ–ščŧ¸å…Ĩ新密įĸŧ。", - "change_password_form_log_out": "č¨ģéŠˇæ‰€æœ‰å…ļäģ–荭備", - "change_password_form_log_out_description": "åģē議退å‡ē所有å…ļäģ–荭備", + "change_password_form_log_out": "į™ģå‡ē所有å…ļäģ–čŖįŊŽ", + "change_password_form_log_out_description": "åģēč­°åžžæ‰€æœ‰å…ļäģ–čŖįŊŽį™ģå‡ē", "change_password_form_new_password": "新密įĸŧ", "change_password_form_password_mismatch": "密įĸŧ不一致", "change_password_form_reenter_new_password": "再æŦĄčŧ¸å…Ĩ新密įĸŧ", "change_pin_code": "čŽŠæ›´ PIN įĸŧ", "change_trigger": "æ›´æ”šč§¸į™ŧ器", - "change_trigger_prompt": "您įĸē厚čĻæ›´æ”šč§¸į™ŧ器嗎īŧŸ é€™å°‡åˆ é™¤æ‰€æœ‰įžæœ‰æ“äŊœå’Œį¯Šé¸å™¨ã€‚", + "change_trigger_prompt": "įĸē厚čĻčŽŠæ›´č§¸į™ŧæĸäģļ嗎īŧŸé€™å°‡į§ģé™¤æ‰€æœ‰įžæœ‰įš„å‹•äŊœčˆ‡į¯Šé¸å™¨ã€‚", "change_your_password": "čŽŠæ›´æ‚¨įš„å¯†įĸŧ", "changed_visibility_successfully": "åˇ˛æˆåŠŸčŽŠæ›´å¯čĻ‹æ€§", "charging": "充é›ģ", "charging_requirement_mobile_backup": "垌č‡ē備äģŊčĻæą‚čŖįŊŽæ­Ŗåœ¨å……é›ģ", "check_corrupt_asset_backup": "æĒĸæŸĨææ¯€įš„å‚™äģŊé …į›Ž", "check_corrupt_asset_backup_button": "åŸˇčĄŒæĒĸæŸĨ", - "check_corrupt_asset_backup_description": "åƒ…åœ¨åˇ˛é€Ŗįˇšč‡ŗ Wi-Fi 且所有åĒ’éĢ”åˇ˛åŽŒæˆå‚™äģŊåžŒåŸˇčĄŒæ­¤æĒĸæŸĨã€‚æ­¤į¨‹åŧå¯čƒŊ需čĻæ•¸åˆ†é˜ã€‚", - "check_logs": "æĒĸæŸĨæ—Ĩčnj", - "checksum": "æ ĄéŠ—å’Œ", + "check_corrupt_asset_backup_description": "åƒ…åœ¨åˇ˛é€Ŗįˇšč‡ŗ Wi-Fi ä¸”æ‰€æœ‰é …į›Žåˇ˛åŽŒæˆå‚™äģŊåžŒåŸˇčĄŒæ­¤æĒĸæŸĨã€‚æ­¤į¨‹åŧå¯čƒŊ需čĻæ•¸åˆ†é˜ã€‚", + "check_logs": "æĒĸæŸĨį´€éŒ„", + "checksum": "æ ĄéŠ—įĸŧ", "choose_matching_people_to_merge": "選擇čρ合äŊĩįš„į›¸įŦĻäēēį‰Š", "city": "城市", - "cleanup_confirm_description": "Immich į™ŧįž {count} å€‹é …į›Žīŧˆåœ¨ {date} 䚋前å‰ĩåģēīŧ‰åˇ˛åމ免備äģŊ到服務器。是åĻåžžæ­¤č¨­å‚™ä¸­åˆĒ除æœŦ地副æœŦīŧŸ", + "cleanup_confirm_description": "Immich į™ŧįžæœ‰ {count} å€‹é …į›ŽīŧˆåģēįĢ‹æ–ŧ {date} 䚋前īŧ‰åˇ˛åމ免備äģŊ臺äŧ翜å™¨ã€‚是åĻčĻåžžæ­¤čŖįŊŽä¸­åˆĒ除æœŦ抟副æœŦīŧŸ", "cleanup_confirm_prompt_title": "åžžæ­¤čŖįŊŽåˆĒ除īŧŸ", "cleanup_deleted_assets": "厞將{count}é …į›Žį§ģåˆ°čŖįŊŽįš„垃圞æĄļčŖĄ", "cleanup_deleting": "æ­Ŗåœ¨į§ģ動到垃圞æĄļ...", "cleanup_found_assets": "扞到{count}äģļåˇ˛ä¸Šå‚ŗįš„é …į›Ž", "cleanup_found_assets_with_size": "扞到{count}äģļīŧŒį¸Ŋå…ą({size})åˇ˛ä¸Šå‚ŗįš„é …į›Ž", "cleanup_icloud_shared_albums_excluded": "iCloudå…ąäēĢᛏį°ŋčĸ̿ޒ除æ–ŧ搜尋䚋外", - "cleanup_no_assets_found": "æœĒ扞到äģģäŊ•įŦĻ合æĸäģļįš„é …į›Žã€‚é‡‹æ”žå…§å­˜åŠŸčƒŊåĒčƒŊį§ģ除厞備äģŊ到äŧ翜å™¨įš„é …į›Ž", + "cleanup_no_assets_found": "扞不到įŦĻ合上čŋ°æĸäģļįš„é …į›Žã€‚é‡‹æ”žįŠē間功čƒŊ僅čƒŊį§ģ除厞備äģŊ臺äŧ翜å™¨įš„é …į›Ž", "cleanup_preview_title": "{count} 項需čρį§ģé™¤įš„é …į›Ž", - "cleanup_step3_description": "掃描įŦĻ合æ—Ĩ期和äŋå­˜č¨­åŽšįš„åˇ˛å‚™äģŊé …į›Žã€‚", - "cleanup_step4_summary": "åžžé€™å°čŖįŊŽä¸Šį§ģ除{count}äģļå‰ĩåģēæ–ŧ{date}å‰įš„é …į›Žã€‚į…§į‰‡äģį„ļ可äģĨ在Immich上æŸĨįœ‹ã€‚", - "cleanup_trash_hint": "čĻåŽŒå…¨æĸ垊內存īŧŒčĢ‹æ¸…įŠēᛏį°ŋä¸­įš„åžƒåœžæĄļ", + "cleanup_step3_description": "掃描įŦĻ合æ—ĨæœŸčˆ‡å„˛å­˜č¨­åŽšįš„åˇ˛å‚™äģŊé …į›Žã€‚", + "cleanup_step4_summary": "å°‡åžžæ­¤čŖįŊŽį§ģ除 {count} 個åģēįĢ‹æ–ŧ {date} äš‹å‰įš„é …į›Žã€‚æ‚¨äģå¯é€éŽ Immich æ‡‰į”¨į¨‹åŧå­˜å–這äē›į›¸į‰‡ã€‚", + "cleanup_trash_hint": "č‹ĨčĻåžšåē•é‡‹æ”žå„˛å­˜įŠē間īŧŒčĢ‹é–‹å•Ÿįŗģįĩąį›¸į°ŋ App ä¸Ļ清įŠē垃圞æĄļ", "clear": "清įŠē", "clear_all": "全部清除", "clear_all_recent_searches": "清除所有最čŋ‘įš„æœå°‹", @@ -785,8 +785,8 @@ "client_cert_password_message": "čĢ‹čŧ¸å…Ĩæ­¤č­‰æ›¸įš„å¯†įĸŧ", "client_cert_password_title": "č­‰æ›¸å¯†įĸŧ", "client_cert_remove_msg": "ᔍæˆļįĢ¯æ†‘č­‰åˇ˛į§ģ除", - "client_cert_subtitle": "僅支援 PKCS12 (.p12, .pfx) æ ŧåŧã€‚僅可在į™ģå…Ĩå‰é€˛čĄŒæ†‘č­‰įš„åŒ¯å…Ĩ和į§ģ除", - "client_cert_title": "SSL ᔍæˆļįĢ¯æ†‘č­‰[å¯Ļ銗性]", + "client_cert_subtitle": "僅支援 PKCS12 (.p12, .pfx) æ ŧåŧã€‚æ†‘č­‰åŒ¯å…Ĩ與į§ģ除僅可在į™ģå…Ĩå‰é€˛čĄŒ", + "client_cert_title": "SSL ᔍæˆļįĢ¯æ†‘č­‰ [å¯Ļ銗性]", "clockwise": "順時針", "close": "關閉", "collapse": "æŠ˜į–Š", @@ -794,6 +794,11 @@ "color": "顏色", "color_theme": "色åŊŠä¸ģ題", "command": "å‘Ŋäģ¤", + "command_palette_prompt": "åŋĢ速尋扞頁éĸīŧŒå‹•äŊœæˆ–č€…æŒ‡äģ¤", + "command_palette_to_close": "關閉", + "command_palette_to_navigate": "čŧ¸å…Ĩ", + "command_palette_to_select": "選擇", + "command_palette_to_show_all": "éĄ¯į¤ē全部", "comment_deleted": "ᕙ荀厞åˆĒ除", "comment_options": "ᕙ荀遏項", "comments_and_likes": "į•™č¨€čˆ‡å–œæ­Ą", @@ -802,9 +807,9 @@ "completed": "åˇ˛åŽŒæˆ", "confirm": "įĸēčĒ", "confirm_admin_password": "įĸēčĒįŽĄį†å“Ąå¯†įĸŧ", - "confirm_delete_face": "您įĸē厚čĻåžžčŠ˛åĒ’é̔䏭åˆĒ除{name}įš„č‡‰å­”å—ŽīŧŸ", - "confirm_delete_shared_link": "您įĸē厚čρåˆĒé™¤é€™å€‹å…ąäēĢ逪įĩå—ŽīŧŸ", - "confirm_keep_this_delete_others": "除此åĒ’é̔外īŧŒå †į–Šä¸­įš„å…ļäģ–åĒ’éĢ”éƒŊ將čĸĢåˆĒ除。您įĸē厚čρįšŧįēŒå—ŽīŧŸ", + "confirm_delete_face": "您įĸē厚čĻåžžčŠ˛é …į›Žä¸­åˆĒ除 {name} įš„č‡‰å­”å—ŽīŧŸ", + "confirm_delete_shared_link": "您įĸē厚čρåˆĒ除此分äēĢ逪įĩå—ŽīŧŸ", + "confirm_keep_this_delete_others": "é™¤æ­¤é …į›Žå¤–īŧŒå †į–Šä¸­įš„å…ļäģ–é …į›ŽéƒŊ將čĸĢåˆĒ除。您įĸē厚čρįšŧįēŒå—ŽīŧŸ", "confirm_new_pin_code": "įĸēčĒæ–° PIN įĸŧ", "confirm_password": "įĸēčĒå¯†įĸŧ", "confirm_tag_face": "æ‚¨æƒŗčĻå°‡æ­¤č‡‰å­”æ¨™įą¤į‚ē {name} 嗎īŧŸ", @@ -837,23 +842,23 @@ "create": "åģēįĢ‹", "create_album": "åģēį̋ᛏį°ŋ", "create_album_page_untitled": "æœĒå‘Ŋ名", - "create_api_key": "å‰ĩåģēAPI金鑰", - "create_first_workflow": "å‰ĩåģēįŦŦ一個åˇĨäŊœæĩ", + "create_api_key": "åģēįĢ‹ API 金鑰", + "create_first_workflow": "åģēįĢ‹įŦŦ一個åˇĨäŊœæĩį¨‹", "create_library": "åģēįĢ‹åĒ’éĢ”åēĢ", "create_link": "åģēį̋逪įĩ", - "create_link_to_share": "åģēįĢ‹å…ąäēĢ逪įĩ", - "create_link_to_share_description": "äģģäŊ•æŒæœ‰é€Ŗįĩįš„äēēéƒŊå…č¨ąæĒĸčĻ–æ‰€é¸į›¸į‰‡", + "create_link_to_share": "åģēįĢ‹åˆ†äēĢ逪įĩ", + "create_link_to_share_description": "æŒæœ‰é€Ŗįĩįš„äēēįš†å¯æĒĸčĻ–æ‰€é¸é …į›Ž", "create_new": "新åĸž", "create_new_person": "åģēįĢ‹æ–°äēēį‰Š", - "create_new_person_hint": "å°‡é¸åŽšįš„åĒ’éĢ”åˆ†é…įĩĻæ–°äēēį‰Š", + "create_new_person_hint": "å°‡é¸å–įš„é …į›ŽæŒ‡æ´žįĩĻæ–°įš„äēēį‰Š", "create_new_user": "åģēįĢ‹æ–°äŊŋᔍ者", - "create_shared_album_page_share_add_assets": "新åĸžč†œéĢ”", - "create_shared_album_page_share_select_photos": "é¸æ“‡į…§į‰‡", - "create_shared_link": "åģēįĢ‹å…ąäēĢ逪įĩ", + "create_shared_album_page_share_add_assets": "新åĸžé …į›Ž", + "create_shared_album_page_share_select_photos": "é¸å–į›¸į‰‡", + "create_shared_link": "åģēįĢ‹åˆ†äēĢ逪įĩ", "create_tag": "åģēįĢ‹æ¨™įą¤", "create_tag_description": "åģēįĢ‹æ–°æ¨™įą¤ã€‚č‹ĨčρåģēįĢ‹åˇĸį‹€æ¨™įą¤īŧŒčĢ‹čŧ¸å…Ĩ包åĢæ­Ŗæ–œįˇšįš„åŽŒæ•´æ¨™įą¤čˇ¯åž‘ã€‚", "create_user": "åģēįĢ‹äŊŋᔍ者", - "create_workflow": "å‰ĩåģēåˇĨäŊœæĩ", + "create_workflow": "åģēįĢ‹åˇĨäŊœæĩį¨‹", "created": "åģēįĢ‹æ–ŧ", "created_at": "åģēįĢ‹æ–ŧ", "creating_linked_albums": "åģēį̋逪įĩį›¸į°ŋ ...", @@ -869,7 +874,7 @@ "custom_locale": "č‡Ēč¨‚åœ°å€č¨­åŽš", "custom_locale_description": "栚據čĒžč¨€čˆ‡åœ°å€æ ŧåŧåŒ–æ—ĨæœŸčˆ‡æ•¸å­—", "custom_url": "č‡Ē訂 URL", - "cutoff_date_description": "äŋį•™æœ€čŋ‘å¤šå°‘å¤Šįš„į…§į‰‡â€Ļ", + "cutoff_date_description": "äŋį•™æœ€čŋ‘å¤šå°‘å¤Šįš„į›¸į‰‡â€Ļ", "cutoff_day": "{count, plural, one {夊} other {夊}}", "cutoff_year": "{count, plural, one {åš´} other {åš´}}", "daily_title_text_date": "E, MMM dd", @@ -889,11 +894,11 @@ "deduplication_criteria_1": "åŊąåƒå¤§å°īŧˆäģĨäŊå…ƒįĩ„į‚ēå–ŽäŊīŧ‰", "deduplication_criteria_2": "EXIF čŗ‡æ–™æ•¸é‡", "deduplication_info": "é‡č¤‡čŗ‡æ–™åˆĒé™¤čŗ‡č¨Š", - "deduplication_info_description": "čρč‡Ē動預先選取åĒ’éĢ”ä¸Ļ扚æŦĄį§ģé™¤é‡č¤‡é …į›ŽīŧŒæˆ‘們會æĒĸæŸĨīŧš", - "default_locale": "é č¨­åœ°å€", + "deduplication_info_description": "č‹Ĩčρč‡Ēå‹•é å…ˆé¸å–é …į›Žä¸Ļ扚æŦĄį§ģé™¤é‡č¤‡é …į›ŽīŧŒæˆ‘們會æĒĸæŸĨīŧš", + "default_locale": "é č¨­åœ°å€č¨­åŽš", "default_locale_description": "äžį…§æ‚¨įš„į€čĻŊå™¨åœ°å€č¨­åŽšæ ŧåŧåŒ–æ—ĨæœŸčˆ‡æ•¸å­—", "delete": "åˆĒ除", - "delete_action_confirmation_message": "您įĸē厚čρåˆĒ除此åĒ’éĢ”å—ŽīŧŸæ­¤æ“äŊœæœƒå°‡čОåĒ’éĢ”į§ģ臺äŧ翜å™¨įš„垃圞æĄļīŧŒä¸Ļ會提į¤ē您是åĻčρ圍æœŦ抟同時åˆĒ除", + "delete_action_confirmation_message": "您įĸē厚čρåˆĒé™¤æ­¤é …į›Žå—ŽīŧŸæ­¤å‹•äŊœæœƒå°‡čŠ˛é …į›Žį§ģ臺äŧ翜å™¨įš„垃圞æĄļīŧŒä¸ĻčŠĸ問您是åĻčρ圍æœŦ抟同æ­ĨåˆĒ除", "delete_action_prompt": "{count} 個厞åˆĒ除", "delete_album": "åˆĒ除ᛏį°ŋ", "delete_api_key_prompt": "您įĸē厚čρåˆĒ除這個 API 金鑰嗎īŧŸ", @@ -914,36 +919,36 @@ "delete_others": "åˆĒ除å…ļäģ–", "delete_permanently": "永䚅åˆĒ除", "delete_permanently_action_prompt": "åˇ˛æ°¸äš…åˆĒ除 {count} å€‹é …į›Ž", - "delete_shared_link": "åˆĒé™¤å…ąäēĢ逪įĩ", - "delete_shared_link_dialog_title": "åˆĒé™¤å…ąäēĢ逪įĩ", + "delete_shared_link": "åˆĒ除分äēĢ逪įĩ", + "delete_shared_link_dialog_title": "åˆĒ除分äēĢ逪įĩ", "delete_tag": "åˆĒé™¤æ¨™įą¤", "delete_tag_confirmation_prompt": "您įĸē厚čρåˆĒ除「{tagName}ã€æ¨™įą¤å—ŽīŧŸ", "delete_user": "åˆĒ除äŊŋᔍ者", "deleted_shared_link": "å…ąäēĢ逪įĩåˇ˛åˆĒ除", - "deletes_missing_assets": "åˆĒ除᪁įĸŸä¸­éēå¤ąįš„åĒ’éĢ”", + "deletes_missing_assets": "åˆĒ除᪁įĸŸä¸­éēå¤ąįš„é …į›Ž", "description": "描čŋ°", "description_input_hint_text": "新åĸžæčŋ°...", - "description_input_submit_error": "更新描čŋ°æ™‚į™ŧį”ŸéŒ¯čǤīŧŒčĢ‹æĒĸæŸĨæ—ĨčnjäģĨå–åž—æ›´å¤ščŠŗį´°čŗ‡č¨Š", + "description_input_submit_error": "更新čĒĒæ˜Žæ™‚į™ŧį”ŸéŒ¯čǤīŧŒčĢ‹æĒĸæŸĨį´€éŒ„äģĨå–åž—æ›´å¤ščŠŗį´°čŗ‡č¨Š", "deselect_all": "取æļˆå…¨é¸", "details": "čŠŗį´°čŗ‡č¨Š", "direction": "斚向", - "disable": "įρᔍ", + "disable": "åœį”¨", "disabled": "åˇ˛åœį”¨", "disallow_edits": "ä¸å…č¨ąįˇ¨čŧ¯", "discord": "Discord", "discover": "æŽĸį´ĸ", - "discovered_devices": "厞æŽĸį´ĸįš„čŖįŊŽ", + "discovered_devices": "厞į™ŧįžįš„čŖįŊŽ", "dismiss_all_errors": "åŋŊį•Ĩ所有錯čǤ", "dismiss_error": "åŋŊį•Ĩ錯čǤ", "display_options": "éĄ¯į¤ē選項", "display_order": "éĄ¯į¤ē順åē", - "display_original_photos": "éĄ¯į¤ēåŽŸå§‹į…§į‰‡", - "display_original_photos_setting_description": "在æĒĸčĻ–åĒ’éĢ”æ™‚īŧŒč‹Ĩ原始åĒ’éĢ”čˆ‡įļ˛é į›¸åŽšīŧŒå‰‡å„Ēå…ˆéĄ¯į¤ēåŽŸå§‹į›¸į‰‡č€Œéžį¸Žåœ–ã€‚é€™å¯čƒŊæœƒå°Žč‡´į…§į‰‡éĄ¯į¤ē速åēĻ變æ…ĸ。", + "display_original_photos": "éĄ¯į¤ēåŽŸå§‹į›¸į‰‡", + "display_original_photos_setting_description": "在æĒĸčĻ–é …į›Žæ™‚īŧŒč‹ĨåŽŸå§‹é …į›Žčˆ‡įļ˛é į›¸åŽšīŧŒå‰‡å„Ēå…ˆéĄ¯į¤ēåŽŸå§‹į›¸į‰‡č€Œéžį¸Žåœ–ã€‚é€™å¯čƒŊæœƒå°Žč‡´į›¸į‰‡čŧ‰å…Ĩ速åēĻ變æ…ĸ。", "do_not_show_again": "ä¸å†éĄ¯į¤ēæ­¤č¨Šæ¯", "documentation": "čĒĒæ˜Žæ–‡äģļ", "done": "厌成", "download": "下čŧ‰", - "download_action_prompt": "æ­Ŗåœ¨ä¸‹čŧ‰ {count} 個åĒ’éĢ”", + "download_action_prompt": "æ­Ŗåœ¨ä¸‹čŧ‰ {count} å€‹é …į›Ž", "download_canceled": "下čŧ‰åˇ˛å–æļˆ", "download_complete": "下čŧ‰åŽŒæˆ", "download_enqueue": "厞加å…Ĩ下čŧ‰äŊ‡åˆ—", @@ -951,23 +956,23 @@ "download_failed": "下čŧ‰å¤ąæ•—", "download_finished": "下čŧ‰åŽŒæˆ", "download_include_embedded_motion_videos": "åĩŒå…ĨåŊąį‰‡", - "download_include_embedded_motion_videos_description": "å°‡å‹•æ…‹į›¸į‰‡ä¸­å…§åĩŒįš„åŊąį‰‡åĻ存į‚ēį¨įĢ‹æĒ”æĄˆ", + "download_include_embedded_motion_videos_description": "å°‡å‹•æ…‹į›¸į‰‡ä¸­å…§åĩŒįš„åŊąį‰‡å„˛å­˜į‚ēį¨įĢ‹æĒ”æĄˆ", "download_notfound": "į„Ąæŗ•æ‰žåˆ°ä¸‹čŧ‰", - "download_original": "下čŧ‰åŽŸå§‹æ–‡äģļ", + "download_original": "下čŧ‰åŽŸå§‹æĒ”æĄˆ", "download_paused": "下čŧ‰åˇ˛æšĢ停", "download_settings": "下čŧ‰", - "download_settings_description": "įŽĄį†čˆ‡åĒ’é̔䏋čŧ‰į›¸é—œįš„設åޚ", + "download_settings_description": "įŽĄį†čˆ‡é …į›Žä¸‹čŧ‰į›¸é—œįš„設åޚ", "download_started": "厞開始䏋čŧ‰", "download_sucess": "下čŧ‰æˆåŠŸ", "download_sucess_android": "åĒ’é̔厞䏋čŧ‰č‡ŗ DCIM/Immich", "download_waiting_to_retry": "į­‰åž…é‡čŠĻ", "downloading": "下čŧ‰ä¸­", - "downloading_asset_filename": "æ­Ŗåœ¨ä¸‹čŧ‰åĒ’éĢ” {filename}", + "downloading_asset_filename": "æ­Ŗåœ¨ä¸‹čŧ‰é …į›Ž {filename}", "downloading_from_icloud": "æ­ŖåžžiCloud下čŧ‰", "downloading_media": "æ­Ŗåœ¨ä¸‹čŧ‰åĒ’éĢ”", "drop_files_to_upload": "將æĒ”æĄˆæ‹–æ”žåˆ°äģģäŊ•äŊįŊŽäģĨä¸Šå‚ŗ", "duplicates": "é‡č¤‡é …į›Ž", - "duplicates_description": "逐一æĒĸæŸĨæ¯å€‹įž¤įĩ„īŧŒä¸Ļ標į¤ēå…ļ中是åĻæœ‰é‡č¤‡åĒ’éĢ”", + "duplicates_description": "逐一æĒĸæŸĨæ¯å€‹įž¤įĩ„īŧŒä¸Ļ標į¤ēå…ļ中是åĻæœ‰é‡č¤‡é …į›Ž", "duration": "éĄ¯į¤ēæ™‚é•ˇ", "edit": "ᎍčŧ¯", "edit_album": "ᎍčŧ¯į›¸į°ŋ", @@ -994,11 +999,11 @@ "edit_user": "ᎍčŧ¯äŊŋᔍ者", "edit_workflow": "ᎍčŧ¯åˇĨäŊœæĩį¨‹", "editor": "ᎍčŧ¯å™¨", - "editor_close_without_save_prompt": "æ­¤čŽŠæ›´å°‡ä¸æœƒčĸĢå„˛å­˜", + "editor_close_without_save_prompt": "čŽŠæ›´å°‡ä¸æœƒčĸĢå„˛å­˜", "editor_close_without_save_title": "čĻé—œé–‰įˇ¨čŧ¯å™¨å—ŽīŧŸ", "editor_confirm_reset_all_changes": "äŊ įĸē厚čĻé‡č¨­æ‰€æœ‰čŽŠæ›´å—ŽīŧŸ", "editor_discard_edits_confirm": "æ”žæŖ„įˇ¨čŧ¯", - "editor_discard_edits_prompt": "äŊ æœ‰æœĒäŋå­˜įš„ᎍčŧ¯ã€‚įĸē厚čĻæ”žæŖ„å—ŽīŧŸ", + "editor_discard_edits_prompt": "您有尚æœĒå„˛å­˜įš„įˇ¨čŧ¯å…§åŽšã€‚įĸē厚čĻæ¨æŖ„å—ŽīŧŸ", "editor_discard_edits_title": "įĸēčĒæ”žæŖ„įˇ¨čŧ¯å—ŽīŧŸ", "editor_edits_applied_error": "į„Ąæŗ•åĨ—ᔍᎍčŧ¯", "editor_edits_applied_success": "åˇ˛æˆåŠŸåĨ—ᔍᎍčŧ¯", @@ -1012,7 +1017,7 @@ "email_notifications": "Email 通įŸĨ", "empty_folder": "é€™å€‹čŗ‡æ–™å¤žæ˜¯įŠēįš„", "empty_trash": "清įŠē垃圞æĄļ", - "empty_trash_confirmation": "您įĸē厚čĻæ¸…įŠē垃圞æĄļ嗎īŧŸé€™æœƒæ°¸äš…åˆĒ除 Immich 垃圞æĄļä¸­æ‰€æœ‰įš„åĒ’éĢ”ã€‚\næ‚¨į„Ąæŗ•æ’¤éŠˇæ­¤čŽŠæ›´īŧ", + "empty_trash_confirmation": "您įĸē厚čĻæ¸…įŠē垃圞æĄļ嗎īŧŸé€™æœƒåžž Immich 永䚅į§ģ除垃圞æĄļä¸­æ‰€æœ‰įš„é …į›Žã€‚\næ‚¨į„Ąæŗ•åžŠåŽŸæ­¤å‹•äŊœīŧ", "enable": "å•Ÿį”¨", "enable_backup": "å•Ÿį”¨å‚™äģŊ", "enable_biometric_auth_description": "čŧ¸å…Ĩæ‚¨įš„ PIN įĸŧäģĨå•Ÿį”¨į”Ÿį‰Ščž¨č­˜éŠ—č­‰", @@ -1021,95 +1026,95 @@ "enqueued": "åˇ˛æŽ’å…ĨäŊ‡åˆ—", "enter_wifi_name": "čŧ¸å…Ĩ Wi-Fi åį¨ą", "enter_your_pin_code": "čŧ¸å…Ĩæ‚¨įš„ PIN įĸŧ", - "enter_your_pin_code_subtitle": "čŧ¸å…Ĩæ‚¨įš„ PIN įĸŧäģĨå­˜å–éŽ–åŽšįš„čŗ‡æ–™å¤ž", + "enter_your_pin_code_subtitle": "čŧ¸å…Ĩæ‚¨įš„ PIN įĸŧäģĨå­˜å–ã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤ž", "error": "錯čǤ", "error_change_sort_album": "čŽŠæ›´į›¸į°ŋ排åēå¤ąæ•—", - "error_delete_face": "åžžåĒ’éĢ”åˆĒé™¤č‡‰å­”æ™‚å¤ąæ•—", + "error_delete_face": "åžžé …į›ŽåˆĒé™¤č‡‰å­”æ™‚į™ŧį”ŸéŒ¯čǤ", "error_getting_places": "取垗äŊįŊŽæ™‚å‡ē錯", - "error_loading_albums": "į„Ąæŗ•åŠ čŧ‰į›¸į°ŋ", + "error_loading_albums": "čŧ‰å…Ĩᛏį°ŋ時į™ŧį”ŸéŒ¯čǤ", "error_loading_image": "åœ–į‰‡čŧ‰å…Ĩ錯čǤ", - "error_loading_partners": "čŧ‰å…Ĩ合äŊœå¤Ĩäŧ´æ™‚å‡ē錯īŧš{error}", - "error_retrieving_asset_information": "į„Ąæŗ•į˛å–é …į›Žčŗ‡č¨Š", + "error_loading_partners": "čŧ‰å…ĨčĻĒ友時į™ŧį”ŸéŒ¯čǤīŧš{error}", + "error_retrieving_asset_information": "į„Ąæŗ•å–åž—é …į›Žčŗ‡č¨Š", "error_saving_image": "錯čǤīŧš{error}", "error_tag_face_bounding_box": "æ¨™č¨˜č‡‰éƒ¨éŒ¯čǤ - į„Ąæŗ•å–åž—é‚Šį•ŒæĄ†åæ¨™", "error_title": "錯čǤ - į™ŧį”ŸéŒ¯čǤ", "error_while_navigating": "į„Ąæŗ•åŧ•å°Žč‡ŗé …į›Ž", "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_metadata_assets_count": "į„Ąæŗ•čŽŠæ›´ {count, plural, 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": "į„Ąæŗ•æœå°‹åœ°éģž", - "error_adding_assets_to_album": "將åĒ’éĢ”åŠ å…Ĩᛏį°ŋ時į™ŧį”ŸéŒ¯čǤ", + "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_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_create_shared_link": "分äēĢ逪įĩåģēįĢ‹å¤ąæ•—", + "failed_to_edit_shared_link": "分äēĢ逪įĩįˇ¨čŧ¯å¤ąæ•—", "failed_to_get_people": "į„Ąæŗ•å–åž—äēēį‰Š", - "failed_to_keep_this_delete_others": "į„Ąæŗ•äŋį•™æ­¤åĒ’éĢ”ä¸ĻåˆĒ除å…ļäģ–åĒ’éĢ”", - "failed_to_load_asset": "åĒ’éĢ”čŧ‰å…Ĩå¤ąæ•—", - "failed_to_load_assets": "åĒ’éĢ”čŧ‰å…Ĩå¤ąæ•—", + "failed_to_keep_this_delete_others": "į„Ąæŗ•äŋį•™æ­¤é …į›Žä¸ĻåˆĒ除å…ļäģ–é …į›Ž", + "failed_to_load_asset": "é …į›Žčŧ‰å…Ĩå¤ąæ•—", + "failed_to_load_assets": "é …į›Žčŧ‰å…Ĩå¤ąæ•—", "failed_to_load_notifications": "čŧ‰å…Ĩ通įŸĨå¤ąæ•—", "failed_to_load_people": "čŧ‰å…Ĩäēēį‰Šå¤ąæ•—", "failed_to_remove_product_key": "į§ģ除į”ĸå“é‡‘é‘°å¤ąæ•—", "failed_to_reset_pin_code": "重設 PIN įĸŧå¤ąæ•—", - "failed_to_stack_assets": "į„Ąæŗ•åĒ’éĢ”å †į–Š", - "failed_to_unstack_assets": "觪除åĒ’éĢ”å †į–Šå¤ąæ•—", + "failed_to_stack_assets": "é …į›Žå †į–Šå¤ąæ•—", + "failed_to_unstack_assets": "č§Ŗé™¤é …į›Žå †į–Šå¤ąæ•—", "failed_to_update_notification_status": "į„Ąæŗ•æ›´æ–°é€šįŸĨį‹€æ…‹", "incorrect_email_or_password": "é›ģ子éƒĩäģ￈–密įĸŧ錯čǤ", - "library_folder_already_exists": "此導å…Ĩčˇ¯åž‘åˇ˛å­˜åœ¨ã€‚", + "library_folder_already_exists": "此匯å…Ĩčˇ¯åž‘åˇ˛å­˜åœ¨ã€‚", "paths_validation_failed": "{paths, plural, one {# å€‹čˇ¯åž‘} other {# å€‹čˇ¯åž‘}} éŠ—č­‰å¤ąæ•—", "profile_picture_transparent_pixels": "個äēēčŗ‡æ–™åœ–į‰‡ä¸čƒŊ有透明į•Ģį´ ã€‚čĢ‹æ”žå¤§ä¸Ļ/或į§ģ動åŊąåƒã€‚", - "quota_higher_than_disk_size": "æ‚¨æ‰€č¨­åŽšįš„é…éĄå¤§æ–ŧ᪁įĸŸå¤§å°", + "quota_higher_than_disk_size": "æ‚¨č¨­åŽšįš„é…éĄå¤§æ–ŧ᪁įĸŸåŽšé‡", "something_went_wrong": "į™ŧį”ŸéŒ¯čǤ", "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_partners": "į„Ąæŗ•æ–°åĸžčĻĒæœ‹åĨŊ友", - "unable_to_add_remove_archive": "į„Ąæŗ•{archived, select, true {垞封存中į§ģ除åĒ’éĢ”} other {將æĒ”æĄˆåŠ å…ĨåĒ’éĢ”}}", - "unable_to_add_remove_favorites": "į„Ąæŗ•å°‡åĒ’éĢ”{favorite, select, true {加å…Ĩæ”ļ藏} other {åžžæ”ļ藏中į§ģ除}}", + "unable_to_add_exclusion_pattern": "į„Ąæŗ•æ–°åĸžæŽ’é™¤æ¨Ąåŧ", + "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_description": "į„Ąæŗ•čŽŠæ›´æčŋ°", - "unable_to_change_favorite": "į„Ąæŗ•čŽŠæ›´åĒ’éĢ”įš„æ”ļč—į‹€æ…‹", + "unable_to_change_favorite": "į„Ąæŗ•čŽŠæ›´é …į›Žįš„æ”ļč—į‹€æ…‹", "unable_to_change_location": "į„Ąæŗ•čŽŠæ›´äŊįŊŽ", "unable_to_change_password": "į„Ąæŗ•čŽŠæ›´å¯†įĸŧ", "unable_to_change_visibility": "į„Ąæŗ•čŽŠæ›´ {count, plural, one {# äŊäēēį‰Š} other {# äŊäēēį‰Š}} įš„å¯čĻ‹æ€§", "unable_to_complete_oauth_login": "į„Ąæŗ•åŽŒæˆ OAuth į™ģå…Ĩ", "unable_to_connect": "į„Ąæŗ•é€Ŗįˇš", - "unable_to_copy_to_clipboard": "į„Ąæŗ•č¤‡čŖŊ到å‰Ēč˛ŧį°ŋīŧŒčĢ‹įĸēäŋæ‚¨æ˜¯äģĨ https 存取æœŦ頁éĸ", - "unable_to_create": "į„Ąæŗ•å‰ĩåģēåˇĨäŊœæĩ", + "unable_to_copy_to_clipboard": "į„Ąæŗ•č¤‡čŖŊ到å‰Ēč˛ŧį°ŋīŧŒčĢ‹įĸēäŋæ‚¨æ­Ŗé€éŽ https 存取此頁éĸ", + "unable_to_create": "į„Ąæŗ•åģēįĢ‹åˇĨäŊœæĩį¨‹", "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_asset": "į„Ąæŗ•åˆĒé™¤é …į›Ž", + "unable_to_delete_assets": "åˆĒé™¤é …į›Žæ™‚į™ŧį”ŸéŒ¯čǤ", "unable_to_delete_exclusion_pattern": "į„Ąæŗ•åˆĒé™¤į¯Šé¸æĸäģļ", - "unable_to_delete_shared_link": "åˆĒé™¤å…ąäēĢ逪įĩå¤ąæ•—", + "unable_to_delete_shared_link": "į„Ąæŗ•åˆĒ除分äēĢ逪įĩ", "unable_to_delete_user": "į„Ąæŗ•åˆĒ除äŊŋᔍ者", - "unable_to_delete_workflow": "į„Ąæŗ•åˆ é™¤åˇĨäŊœæĩ", + "unable_to_delete_workflow": "į„Ąæŗ•åˆĒ除åˇĨäŊœæĩį¨‹", "unable_to_download_files": "į„Ąæŗ•ä¸‹čŧ‰æĒ”æĄˆ", "unable_to_edit_exclusion_pattern": "į„Ąæŗ•įˇ¨čŧ¯į¯Šé¸æĸäģļ", "unable_to_empty_trash": "į„Ąæŗ•æ¸…įŠē垃圞æĄļ", "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 å¸ŗč™Ÿ", @@ -1117,19 +1122,19 @@ "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_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_assets_from_shared_link": "į„Ąæŗ•åžžåˆ†äēĢ逪įĩä¸­į§ģé™¤é …į›Ž", "unable_to_remove_library": "į„Ąæŗ•į§ģ除åĒ’éĢ”åēĢ", - "unable_to_remove_partner": "į„Ąæŗ•į§ģ除čĻĒæœ‹åĨŊ友", + "unable_to_remove_partner": "į„Ąæŗ•į§ģ除čĻĒ友", "unable_to_remove_reaction": "į„Ąæŗ•į§ģ除反應", "unable_to_reset_password": "į„Ąæŗ•é‡č¨­å¯†įĸŧ", "unable_to_reset_pin_code": "į„Ąæŗ•é‡č¨­ PIN įĸŧ", "unable_to_resolve_duplicate": "į„Ąæŗ•č§Ŗæąēé‡č¤‡é …į›Ž", - "unable_to_restore_assets": "į„Ąæŗ•é‚„åŽŸåĒ’éĢ”", + "unable_to_restore_assets": "į„Ąæŗ•é‚„åŽŸé …į›Ž", "unable_to_restore_trash": "į„Ąæŗ•é‚„åŽŸåžƒåœžæĄļ", "unable_to_restore_user": "į„Ąæŗ•é‚„åŽŸäŊŋᔍ者", "unable_to_save_album": "į„Ąæŗ•å„˛å­˜į›¸į°ŋ", @@ -1140,11 +1145,11 @@ "unable_to_save_settings": "į„Ąæŗ•å„˛å­˜č¨­åŽš", "unable_to_scan_libraries": "į„Ąæŗ•æŽƒæåĒ’éĢ”åēĢ", "unable_to_scan_library": "į„Ąæŗ•æŽƒæåĒ’éĢ”åēĢ", - "unable_to_set_feature_photo": "į„Ąæŗ•č¨­åŽšå°éĸåœ–į‰‡", + "unable_to_set_feature_photo": "į„Ąæŗ•č¨­åŽšį˛žé¸į›¸į‰‡", "unable_to_set_profile_picture": "į„Ąæŗ•č¨­åŽšå€‹äēēčŗ‡æ–™åœ–į‰‡", "unable_to_set_rating": "į„Ąæŗ•č¨­åŽščŠ•æ˜Ÿ", "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": "į„Ąæŗ•æ›´æ–°į›¸į°ŋ封éĸ", @@ -1154,12 +1159,12 @@ "unable_to_update_settings": "į„Ąæŗ•æ›´æ–°č¨­åŽš", "unable_to_update_timeline_display_status": "į„Ąæŗ•æ›´æ–°æ™‚é–“čģ¸éĄ¯į¤ēį‹€æ…‹", "unable_to_update_user": "į„Ąæŗ•æ›´æ–°äŊŋᔍ者", - "unable_to_update_workflow": "į„Ąæŗ•æ›´æ–°åˇĨäŊœæĩ", + "unable_to_update_workflow": "į„Ąæŗ•æ›´æ–°åˇĨäŊœæĩį¨‹", "unable_to_upload_file": "į„Ąæŗ•ä¸Šå‚ŗæĒ”æĄˆ" }, "errors_text": "錯čǤ", "exclusion_pattern": "æŽ’é™¤æ¨Ąåŧ", - "exif": "EXIF 可ä礿›åŊąåƒæĒ”æ ŧåŧ", + "exif": "EXIF", "exif_bottom_sheet_description": "新åĸžæčŋ°...", "exif_bottom_sheet_description_error": "更新描čŋ°æ™‚į™ŧį”ŸéŒ¯čǤ", "exif_bottom_sheet_details": "čŠŗį´°čŗ‡æ–™", @@ -1168,6 +1173,7 @@ "exif_bottom_sheet_people": "äēēį‰Š", "exif_bottom_sheet_person_add_person": "新åĸžå§“名", "exit_slideshow": "įĩæŸåšģį‡ˆį‰‡", + "expand": "åą•é–‹", "expand_all": "åą•é–‹å…¨éƒ¨", "experimental_settings_new_asset_list_subtitle": "æ­Ŗåœ¨č™•į†", "experimental_settings_new_asset_list_title": "å•Ÿį”¨å¯ĻéŠ—æ€§į›¸į‰‡æ ŧį‹€į‰ˆéĸ", @@ -1186,34 +1192,34 @@ "external": "外部", "external_libraries": "外部åĒ’éĢ”åēĢ", "external_network": "外部įļ˛čˇ¯", - "external_network_sheet_info": "č‹ĨæœĒé€Ŗįˇšč‡ŗååĨŊįš„ Wi-FiīŧŒå°‡äžåˆ—čĄ¨åžžä¸Šåˆ°ä¸‹é¸æ“‡å¯é€Ŗįˇšįš„äŧ翜å™¨įļ˛å€", - "face_unassigned": "æœĒ指厚", + "external_network_sheet_info": "č‹ĨæœĒ逪įļ˛č‡ŗååĨŊįš„ Wi-FiīŧŒå°‡äžæ¸…å–Žåžžä¸Šåˆ°ä¸‹é¸æ“‡å¯é€Ŗįˇšįš„äŧ翜å™¨įļ˛å€", + "face_unassigned": "æœĒ指洞", "failed": "å¤ąæ•—", "failed_count": "å¤ąæ•—īŧš{count}", "failed_to_authenticate": "čēĢäģŊéŠ—č­‰å¤ąæ•—", - "failed_to_load_assets": "į„Ąæŗ•čŧ‰å…ĨåĒ’éĢ”", + "failed_to_load_assets": "é …į›Žčŧ‰å…Ĩå¤ąæ•—", "failed_to_load_folder": "į„Ąæŗ•čŧ‰å…Ĩčŗ‡æ–™å¤ž", "favorite": "æ”ļ藏", - "favorite_action_prompt": "åˇ˛æ–°åĸž {count} 個到æ”ļ藏", - "favorite_or_unfavorite_photo": "æ”ļč—æˆ–å–æļˆæ”ļč—į…§į‰‡", + "favorite_action_prompt": "厞將 {count} å€‹é …į›ŽåŠ å…Ĩæ”ļ藏", + "favorite_or_unfavorite_photo": "æ”ļč—æˆ–å–æļˆæ”ļč—į›¸į‰‡", "favorites": "æ”ļ藏", "favorites_page_no_favorites": "æœĒ扞到æ”ļč—é …į›Ž", - "feature_photo_updated": "į‰šč‰˛į…§į‰‡åˇ˛æ›´æ–°", + "feature_photo_updated": "į˛žé¸į›¸į‰‡åˇ˛æ›´æ–°", "features": "功čƒŊ", "features_in_development": "į™ŧåą•ä¸­įš„į‰šéģž", "features_setting_description": "įŽĄį†æ‡‰į”¨į¨‹åŧåŠŸčƒŊ", "file_name_or_extension": "æĒ”æĄˆåį¨ąæˆ–å‰¯æĒ”名", "file_name_text": "æĒ”æĄˆåį¨ą", "file_name_with_value": "æĒ”æĄˆåį¨ą: {file_name}", - "file_size": "文äģļ大小", + "file_size": "æĒ”æĄˆå¤§å°", "filename": "æĒ”æĄˆåį¨ą", "filetype": "æĒ”æĄˆéĄžåž‹", "filter": "æŋžéĄ", - "filter_description": "į¯Šé¸į›Žæ¨™čŗ‡į”ĸįš„æĸäģļ", + "filter_description": "į¯Šé¸į›Žæ¨™é …į›Žįš„æĸäģļ", "filter_people": "į¯Šé¸äēēį‰Š", "filter_places": "į¯Šé¸åœ°éģž", - "filters": "į¯ŠæĒĸፋåŧ", - "find_them_fast": "é€éŽæœå°‹åį¨ąåŋĢ速扞到äģ–們", + "filters": "į¯Šé¸å™¨", + "find_them_fast": "透過搜尋姓名åŋĢ速扞到äģ–們", "first": "įŦŦ一個", "fix_incorrect_match": "äŋŽåžŠä¸į›¸įŦĻįš„", "folder": "čŗ‡æ–™å¤ž", @@ -1222,16 +1228,16 @@ "folders_feature_description": "é€éŽčŗ‡æ–™å¤žæĒĸčĻ–į€čĻŊæĒ”æĄˆįŗģįĩąä¸­įš„į›¸į‰‡čˆ‡åŊąį‰‡", "forgot_pin_code_question": "åŋ˜č¨˜æ‚¨įš„ PIN įĸŧīŧŸ", "forward": "į”ąæ–°č‡ŗčˆŠ", - "free_up_space": "釋攞內存", - "free_up_space_description": "厞備äģŊį…§į‰‡å’ŒåŊąį‰‡åˇ˛įļ“į§ģåˆ°čŖįŊŽįš„垃圞æĄļäģĨ釋攞內存。äŧ翜å™¨ä¸Šįš„å­˜æĒ”䞝į„ļ厉全。", - "free_up_space_settings_subtitle": "é‡‹æ”žčŖįŊŽå…§å­˜", + "free_up_space": "釋攞įŠē間", + "free_up_space_description": "將厞備äģŊįš„į›¸į‰‡čˆ‡åŊąį‰‡į§ģč‡ŗčŖįŊŽåžƒåœžæĄļäģĨ釋攞įŠē間。äŧ翜å™¨ä¸Šįš„å‚™äģŊ將äŋæŒåŽ‰å…¨ã€‚", + "free_up_space_settings_subtitle": "é‡‹æ”žčŖįŊŽå„˛å­˜įŠē間", "full_path": "åŽŒæ•´čˇ¯åž‘īŧš{path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "此功čƒŊ需čĻåžž Google čŧ‰å…Ĩå¤–éƒ¨čŗ‡æēæ‰čƒŊæ­Ŗå¸¸é‹äŊœã€‚", "general": "一čˆŦ", "geolocation_instruction_location": "éģžé¸å…ˇæœ‰ GPS åē§æ¨™įš„é …į›ŽäģĨäŊŋᔍå…ļäŊįŊŽīŧŒæˆ–į›´æŽĨ垞地圖中選擇地éģž", "get_help": "取垗協劊", - "get_people_error": "į˛å–äēēå“Ąæ™‚å‡ē錯", + "get_people_error": "取垗äēēį‰Šæ™‚į™ŧį”ŸéŒ¯čǤ", "get_wifiname_error": "į„Ąæŗ•å–åž— Wi-Fi åį¨ąã€‚čĢ‹įĸēčĒæ‚¨åˇ˛æŽˆäēˆåŋ…čĻįš„æŦŠé™īŧŒä¸Ļåˇ˛é€Ŗįˇšč‡ŗ Wi-Fi įļ˛čˇ¯", "getting_started": "開始äŊŋᔍ", "go_back": "上一頁", @@ -1242,15 +1248,15 @@ "grant_permission": "授ä爿ŦŠé™", "group_albums_by": "åˆ†éĄžįž¤įĩ„įš„æ–šåŧ...", "group_country": "æŒ‰į…§åœ‹åŽļåˆ†éĄž", - "group_no": "æ˛’æœ‰åˆ†éĄž", + "group_no": "不分įĩ„", "group_owner": "æŒ‰æ“æœ‰č€…åˆ†éĄž", "group_places_by": "åˆ†éĄžåœ°éģžįš„æ–šåŧ...", "group_year": "按嚴äģŊåˆ†éĄž", "haptic_feedback_switch": "å•Ÿį”¨éœ‡å‹•å›žéĨ‹", "haptic_feedback_title": "震動回éĨ‹", "has_quota": "åˇ˛č¨­åŽšé…éĄ", - "hash_asset": "雜暊åĒ’éĢ”", - "hashed_assets": "åˇ˛é›œæšŠįš„åĒ’éĢ”", + "hash_asset": "é›œæšŠé …į›Ž", + "hashed_assets": "åˇ˛é›œæšŠįš„é …į›Ž", "hashing": "æ­Ŗåœ¨č¨ˆįŽ—é›œæšŠå€ŧ", "header_settings_add_header_tip": "新åĸžæ¨™é ­", "header_settings_field_validator_msg": "å€ŧ不可į‚ēįŠē", @@ -1265,42 +1271,42 @@ "hide_password": "éšąč—å¯†įĸŧ", "hide_person": "隱藏äēēį‰Š", "hide_schema": "隱藏æžļ構", - "hide_text_recognition": "éšąč—æ–‡å­—č­˜åˆĨ", + "hide_text_recognition": "éšąč—æ–‡å­—čž¨č­˜", "hide_unnamed_people": "隱藏æœĒå‘Ŋåįš„äēēį‰Š", - "home_page_add_to_album_conflicts": "厞將 {added} 個åĒ’éĢ”æ–°åĸžåˆ°į›¸į°ŋ {album}。{failed} 個åĒ’éĢ”åˇ˛åœ¨čŠ˛į›¸į°ŋ中。", - "home_page_add_to_album_err_local": "æšĢ時不čƒŊ將æœŦ抟åĒ’éĢ”æ–°åĸžåˆ°į›¸į°ŋīŧŒåˇ˛į•Ĩ過", - "home_page_add_to_album_success": "厞圍 {album} ᛏį°ŋ中新åĸž {added} 個åĒ’éĢ”ã€‚", - "home_page_album_err_partner": "æšĢ時不čƒŊį„Ąæŗ•å°‡čĻĒæœ‹åĨŊå‹įš„åĒ’éĢ”æ–°åĸžåˆ°į›¸į°ŋīŧŒåˇ˛į•Ĩ過", - "home_page_archive_err_local": "æšĢ時不čƒŊ封存æœŦ抟åĒ’éĢ”īŧŒåˇ˛į•Ĩ過", - "home_page_archive_err_partner": "į„Ąæŗ•å°å­˜čĻĒæœ‹åĨŊå‹įš„åĒ’éĢ”īŧŒåˇ˛į•Ĩ過", + "home_page_add_to_album_conflicts": "厞將 {added} å€‹é …į›Žæ–°åĸžč‡ŗį›¸į°ŋ {album}。{failed} å€‹é …į›Žåˇ˛åœ¨čŠ˛į›¸į°ŋ中。", + "home_page_add_to_album_err_local": "į›Žå‰į„Ąæŗ•å°‡æœŦæŠŸé …į›Žæ–°åĸžč‡ŗį›¸į°ŋīŧŒåˇ˛į•Ĩ過", + "home_page_add_to_album_success": "厞將 {added} å€‹é …į›Žæ–°åĸžč‡ŗį›¸į°ŋ {album}。", + "home_page_album_err_partner": "į›Žå‰į„Ąæŗ•å°‡čĻĒå‹å…ąäēĢé …į›Žæ–°åĸžč‡ŗį›¸į°ŋīŧŒåˇ˛į•Ĩ過", + "home_page_archive_err_local": "į›Žå‰į„Ąæŗ•å°å­˜æœŦæŠŸé …į›ŽīŧŒåˇ˛į•Ĩ過", + "home_page_archive_err_partner": "į„Ąæŗ•å°å­˜čĻĒå‹å…ąäēĢé …į›ŽīŧŒåˇ˛į•Ĩ過", "home_page_building_timeline": "æ­Ŗåœ¨åģēįĢ‹æ™‚é–“čģ¸", - "home_page_delete_err_partner": "į„Ąæŗ•åˆĒ除čĻĒæœ‹åĨŊå‹įš„åĒ’éĢ”īŧŒåˇ˛į•Ĩ過", - "home_page_delete_remote_err_local": "åˆĒ除遠į̝åĒ’éĢ”įš„é¸å–ä¸­åŒ…åĢæœŦ抟åĒ’éĢ”īŧŒåˇ˛į•Ĩ過", - "home_page_favorite_err_local": "æšĢ不čƒŊæ”ļ藏æœŦæŠŸé …į›ŽīŧŒį•Ĩ過", - "home_page_favorite_err_partner": "æšĢį„Ąæŗ•æ”ļ藏čĻĒæœ‹åĨŊå‹įš„é …į›ŽīŧŒį•Ĩ過", - "home_page_first_time_notice": "åĻ‚æžœé€™æ˜¯æ‚¨įŦŦ一æŦĄäŊŋᔍæœŦፋåŧīŧŒčĢ‹įĸēäŋé¸æ“‡ä¸€å€‹čρ備äģŊįš„į›¸į°ŋīŧŒäģĨå°‡į…§į‰‡čˆ‡åŊąį‰‡åŠ å…Ĩ時間čģ¸", - "home_page_locked_error_local": "į„Ąæŗ•į§ģ動æœŦ抟æĒ”æĄˆč‡ŗéŽ–åŽšįš„čŗ‡æ–™å¤žīŧŒåˇ˛į•Ĩ過", - "home_page_locked_error_partner": "į„Ąæŗ•į§ģ動čĻĒæœ‹åĨŊ友分äēĢįš„åĒ’éĢ”č‡ŗéŽ–åŽšįš„čŗ‡æ–™å¤žīŧŒåˇ˛į•Ĩ過", - "home_page_share_err_local": "į„Ąæŗ•é€éŽé€Ŗįĩå…ąäēĢæœŦ抟åĒ’éĢ”īŧŒåˇ˛į•Ĩ過", - "home_page_upload_err_limit": "一æŦĄæœ€å¤šéšģčƒŊä¸Šå‚ŗ 30 個åĒ’éĢ”īŧŒåˇ˛į•Ĩ過", + "home_page_delete_err_partner": "į„Ąæŗ•åˆĒ除čĻĒå‹å…ąäēĢé …į›ŽīŧŒåˇ˛į•Ĩ過", + "home_page_delete_remote_err_local": "é¸å–įš„é į̝åˆĒ除清喎包åĢæœŦæŠŸé …į›ŽīŧŒåˇ˛į•Ĩ過", + "home_page_favorite_err_local": "æšĢæ™‚į„Ąæŗ•å°‡æœŦæŠŸé …į›Žč¨­į‚ēæ”ļ藏īŧŒåˇ˛į•Ĩ過", + "home_page_favorite_err_partner": "æšĢæ™‚į„Ąæŗ•å°‡čĻĒå‹å…ąäēĢé …į›Žč¨­į‚ēæ”ļ藏īŧŒåˇ˛į•Ĩ過", + "home_page_first_time_notice": "åĻ‚æžœé€™æ˜¯æ‚¨įŦŦ一æŦĄäŊŋᔍæœŦፋåŧīŧŒčĢ‹įĸēäŋé¸æ“‡ä¸€å€‹čρ備äģŊįš„į›¸į°ŋīŧŒäģĨå°‡į›¸į‰‡čˆ‡åŊąį‰‡åŠ å…Ĩ時間čģ¸", + "home_page_locked_error_local": "į„Ąæŗ•å°‡æœŦæŠŸé …į›Žį§ģå‹•č‡ŗã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žīŧŒåˇ˛į•Ĩ過", + "home_page_locked_error_partner": "į„Ąæŗ•å°‡čĻĒå‹å…ąäēĢé …į›Žį§ģå‹•č‡ŗã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žīŧŒåˇ˛į•Ĩ過", + "home_page_share_err_local": "į„Ąæŗ•é€éŽé€Ŗįĩåˆ†äēĢæœŦæŠŸé …į›ŽīŧŒåˇ˛į•Ĩ過", + "home_page_upload_err_limit": "一æŦĄæœ€å¤šåĒčƒŊä¸Šå‚ŗ 30 å€‹é …į›ŽīŧŒåˇ˛į•Ĩ過", "host": "ä¸ģ抟", "hour": "小時", "hours": "小時", "id": "ID", "idle": "閒įŊŽ", - "ignore_icloud_photos": "åŋŊį•Ĩ iCloud ᅧቇ", - "ignore_icloud_photos_description": "å„˛å­˜åœ¨ iCloud ä¸­įš„į…§į‰‡ä¸æœƒä¸Šå‚ŗč‡ŗ Immich äŧ翜å™¨", + "ignore_icloud_photos": "åŋŊį•Ĩ iCloud ᛏቇ", + "ignore_icloud_photos_description": "å„˛å­˜åœ¨ iCloud ä¸­įš„į›¸į‰‡ä¸æœƒä¸Šå‚ŗč‡ŗ Immich äŧ翜å™¨", "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": "{person1} 和 {person2} 一同æ–ŧ {date} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", - "image_alt_text_date_3_people": "{person1}、{person2} 和 {person3} 一同æ–ŧ {date} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", - "image_alt_text_date_4_or_more_people": "{person1}、{person2} 和å…ļäģ– {additionalCount, number} äēēæ–ŧ {date} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", + "image_alt_text_date_1_person": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 與 {person1} 一同拍攝", + "image_alt_text_date_2_people": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 與 {person1} 及 {person2} 一同拍攝", + "image_alt_text_date_3_people": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 與 {person1}、{person2} 及 {person3} 一同拍攝", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 與 {person1}、{person2} 及å…ļäģ– {additionalCount, number} äēē一同拍攝", "image_alt_text_date_place": "æ–ŧ {date} 在 {country} - {city} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", - "image_alt_text_date_place_1_person": "在 {country} - {city}īŧŒčˆ‡ {person1} 一同æ–ŧ {date} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", - "image_alt_text_date_place_2_people": "在 {country} - {city} 與 {person1} 和 {person2} 一同æ–ŧ {date} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", - "image_alt_text_date_place_3_people": "在 {country} - {city} 與 {person1}、{person2} 和 {person3} 一同æ–ŧ {date} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", - "image_alt_text_date_place_4_or_more_people": "在 {country} - {city} 與 {person1}、{person2} 和å…ļäģ– {additionalCount, number} äēēæ–ŧ {date} æ‹æ”įš„{isVideo, select, true {åŊąį‰‡} other {åœ–į‰‡}}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 在 {country}{city} 與 {person1} 一同拍攝", + "image_alt_text_date_place_2_people": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 在 {country}{city} 與 {person1} 及 {person2} 一同拍攝", + "image_alt_text_date_place_3_people": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 在 {country}{city} 與 {person1}、{person2} 及 {person3} 一同拍攝", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {åŊąį‰‡} other {ᛏቇ}}īŧšæ–ŧ {date} 在 {country}{city} 與 {person1}、{person2} 及å…ļäģ– {additionalCount, number} äēē一同拍攝", "image_saved_successfully": "åˇ˛å„˛å­˜åœ–į‰‡", "image_viewer_page_state_provider_download_started": "下čŧ‰åˇ˛å•Ÿå‹•", "image_viewer_page_state_provider_download_success": "下čŧ‰æˆåŠŸ", @@ -1315,7 +1321,7 @@ "in_year_selector": "在", "include_archived": "包åĢ厞封存", "include_shared_albums": "包åĢå…ąäēĢᛏį°ŋ", - "include_shared_partner_assets": "包æ‹Ŧå…ąäēĢčĻĒæœ‹åĨŊå‹įš„åĒ’éĢ”", + "include_shared_partner_assets": "包åĢčĻĒå‹å…ąäēĢé …į›Ž", "individual_share": "個åˆĨ分äēĢ", "individual_shares": "個åˆĨ分äēĢ", "info": "čŗ‡č¨Š", @@ -1327,7 +1333,7 @@ }, "invalid_date": "į„Ąæ•ˆįš„æ—Ĩ期", "invalid_date_format": "į„Ąæ•ˆįš„æ—Ĩ期æ ŧåŧ", - "invite_people": "邀čĢ‹äēēå“Ą", + "invite_people": "邀čĢ‹æˆå“Ą", "invite_to_album": "邀č̋臺ᛏį°ŋ", "ios_debug_info_fetch_ran_at": "æŠ“å–åˇ˛æ–ŧ {dateTime} åŸˇčĄŒ", "ios_debug_info_last_sync_at": "上æŦĄåŒæ­Ĩæ–ŧ {dateTime}", @@ -1343,7 +1349,7 @@ "keep_albums": "äŋį•™į›¸į°ŋ", "keep_albums_count": "äŋį•™{count} {count, plural, one {個ᛏį°ŋ} other {個ᛏį°ŋ}}", "keep_all": "全部äŋį•™", - "keep_description": "選擇釋攞įŠē間時īŧŒäŋį•™åœ¨čŖįŊŽä¸Šįš„ᛏቇ", + "keep_description": "é¸æ“‡åŸˇčĄŒé‡‹æ”žįŠē間時čρäŋį•™åœ¨čŖįŊŽä¸Šįš„é …į›Žã€‚", "keep_favorites": "äŋį•™æœ€æ„›įš„ᛏቇ", "keep_on_device": "äŋį•™åœ¨čŖįŊŽä¸Š", "keep_on_device_hint": "選擇äŋį•™åœ¨čŖįŊŽä¸Šįš„ᛏቇ", @@ -1368,13 +1374,13 @@ "let_others_respond": "å…č¨ąäģ–äēē回čφ", "level": "į­‰į´š", "library": "åĒ’éĢ”åēĢ", - "library_add_folder": "æˇģåŠ čŗ‡æ–™å¤ž", + "library_add_folder": "新åĸžčŗ‡æ–™å¤ž", "library_edit_folder": "ᎍčŧ¯čŗ‡æ–™å¤ž", "library_options": "åĒ’éĢ”åēĢ選項", "library_page_device_albums": "čŖįŊŽä¸Šįš„ᛏį°ŋ", "library_page_new_album": "新åĸžį›¸į°ŋ", "library_page_sort_asset_count": "é …į›Žæ•¸é‡", - "library_page_sort_created": "新åĸžæ—Ĩ期", + "library_page_sort_created": "åģēįĢ‹æ—Ĩ期", "library_page_sort_last_modified": "上æŦĄäŋŽæ”š", "library_page_sort_title": "ᛏį°ŋæ¨™éĄŒ", "licenses": "授æŦŠ", @@ -1384,7 +1390,7 @@ "link_motion_video": "逪įĩå‹•æ…‹åŊąį‰‡", "link_to_oauth": "逪įĩ OAuth", "linked_oauth_account": "厞逪įĩ OAuth å¸ŗč™Ÿ", - "list": "åˆ—čĄ¨", + "list": "清喎", "loading": "čŧ‰å…Ĩ中", "loading_search_results_failed": "čŧ‰å…Ĩ搜尋įĩæžœå¤ąæ•—", "local": "æœŦ抟", @@ -1403,8 +1409,8 @@ "location_picker_longitude_error": "čŧ¸å…Ĩæœ‰æ•ˆįš„įļ“åēĻå€ŧ", "location_picker_longitude_hint": "čĢ‹åœ¨æ­¤č™•čŧ¸å…Ĩæ‚¨įš„įļ“åēĻå€ŧ", "lock": "鎖厚", - "locked_folder": "éŽ–åŽšįš„čŗ‡æ–™å¤ž", - "log_detail_title": "æ—ĨčĒŒčŠŗį´°čŗ‡č¨Š", + "locked_folder": "åˇ˛éŽ–åŽščŗ‡æ–™å¤ž", + "log_detail_title": "į´€éŒ„čŠŗį´°čŗ‡č¨Š", "log_out": "į™ģå‡ē", "log_out_all_devices": "į™ģå‡ēæ‰€æœ‰čŖįŊŽ", "logged_in_as": "äģĨ{user}čēĢ分į™ģå…Ĩ", @@ -1420,12 +1426,12 @@ "login_form_err_http": "čĢ‹č¨ģ明 http:// 或 https://", "login_form_err_invalid_email": "é›ģ子éƒĩäģļåœ°å€į„Ąæ•ˆ", "login_form_err_invalid_url": "į„Ąæ•ˆįš„ URL", - "login_form_err_leading_whitespace": "å¸ļ有前導įŠēæ ŧ", - "login_form_err_trailing_whitespace": "å¸ļ有尞隨įŠēæ ŧ", - "login_form_failed_get_oauth_server_config": "äŊŋᔍ OAuth į™ģå…Ĩ時錯čǤīŧŒčĢ‹æĒĸæŸĨäŧ翜å™¨äŊå€", + "login_form_err_leading_whitespace": "開頭包åĢįŠēį™Ŋ字元", + "login_form_err_trailing_whitespace": "įĩå°žåŒ…åĢįŠēį™Ŋ字元", + "login_form_failed_get_oauth_server_config": "äŊŋᔍ OAuth į™ģå…Ĩ時į™ŧį”ŸéŒ¯čǤīŧŒčĢ‹æĒĸæŸĨäŧ翜å™¨įļ˛å€", "login_form_failed_get_oauth_server_disable": "OAuth 功čƒŊ在此äŧ翜å™¨ä¸Šį„Ąæŗ•äŊŋᔍ", "login_form_failed_login": "į™ģå…Ĩå¤ąæ•—īŧŒčĢ‹æĒĸæŸĨäŧ翜å™¨äŊå€ã€é›ģ子éƒĩäģļåœ°å€čˆ‡å¯†įĸŧ", - "login_form_handshake_exception": "與äŧ翜å™¨é€šč¨Šæ™‚å‡ēįžæĄæ‰‹į•°å¸¸ã€‚č‹ĨäŊŋᔍč‡Ēį°Ŋåæ†‘č­‰īŧŒčĢ‹åœ¨č¨­åŽšä¸­å•Ÿį”¨č‡Ēį°Ŋåæ†‘č­‰æ”¯æ´ã€‚", + "login_form_handshake_exception": "與äŧ翜å™¨é€šč¨Šæ™‚å‡ēįžä礿Ąį•°å¸¸ã€‚č‹ĨäŊŋᔍč‡Ēį°Ŋæ†‘č­‰īŧŒčĢ‹åœ¨č¨­åŽšä¸­å•Ÿį”¨č‡Ēį°Ŋæ†‘č­‰æ”¯æ´ã€‚", "login_form_password_hint": "密įĸŧ", "login_form_save_login": "äŋæŒį™ģå…Ĩ", "login_form_server_empty": "čĢ‹čŧ¸å…Ĩäŧ翜å™¨įļ˛å€ã€‚", @@ -1435,59 +1441,59 @@ "login_password_changed_success": "密įĸŧ更新成功", "logout_all_device_confirmation": "您įĸē厚čρį™ģå‡ēæ‰€æœ‰čŖįŊŽå—ŽīŧŸ", "logout_this_device_confirmation": "čρį™ģå‡ē這č‡ēčŖįŊŽå—ŽīŧŸ", - "logs": "æ—Ĩčnj", + "logs": "į´€éŒ„", "longitude": "įļ“åēĻ", "look": "æ¨Ŗč˛Œ", "loop_videos": "重播åŊąį‰‡", "loop_videos_description": "å•Ÿį”¨åžŒīŧŒåŊąį‰‡įĩæŸæœƒč‡Ē動重播。", - "main_branch_warning": "æ‚¨įžåœ¨äŊŋį”¨įš„æ˜¯é–‹į™ŧį‰ˆæœŦīŧ›æˆ‘們åŧˇįƒˆæ‚¨åģēč­°äŊŋį”¨æ­Ŗåŧį™ŧčĄŒį‰ˆīŧ", + "main_branch_warning": "æ‚¨æ­ŖäŊŋᔍ開į™ŧį‰ˆæœŦīŧ›åŧˇįƒˆåģēč­°äŊŋį”¨æ­Ŗåŧį‰ˆæœŦīŧ", "main_menu": "ä¸ģ選喎", - "maintenance_action_restore": "åžŠåŽŸčŗ‡æ–™åēĢ", - "maintenance_description": "Immich厞逞å…Ĩįļ­č­ˇæ¨Ąåŧã€‚", + "maintenance_action_restore": "é‚„åŽŸčŗ‡æ–™åēĢ", + "maintenance_description": "Immich 厞逞å…Ĩ įļ­č­ˇæ¨Ąåŧã€‚", "maintenance_end": "įĩæŸįļ­č­ˇæ¨Ąåŧ", "maintenance_end_error": "æœĒčƒŊįĩæŸįļ­č­ˇæ¨Ąåŧã€‚", - "maintenance_logged_in_as": "į•ļ前äģĨ{user}čēĢäģŊį™ģå…Ĩ", - "maintenance_restore_from_backup": "åžžå‚™äģŊ垊原", - "maintenance_restore_library": "垊原äŊ įš„ᛏį°ŋ", - "maintenance_restore_library_confirm": "įĸēčĒæ˜¯åĻæ­ŖįĸēīŧŒå°‡įšŧįēŒåžžå‚™äģŊ垊原īŧ", - "maintenance_restore_library_description": "æ­Ŗåœ¨åžŠåŽŸčŗ‡æ–™åēĢ", - "maintenance_restore_library_folder_has_files": "{folder}有{count}å€‹čŗ‡æ–™å¤ž", - "maintenance_restore_library_folder_no_files": "{folder}有įŧēå¤ąįš„æĒ”æĄˆīŧ", - "maintenance_restore_library_folder_pass": "可äģĨ讀å¯Ģ", + "maintenance_logged_in_as": "į›Žå‰äģĨ {user} čēĢ分į™ģå…Ĩ", + "maintenance_restore_from_backup": "åžžå‚™äģŊ還原", + "maintenance_restore_library": "é‚„åŽŸæ‚¨įš„åĒ’éĢ”åēĢ", + "maintenance_restore_library_confirm": "įĸēčĒæ˜¯åĻæ­ŖįĸēīŧŒå°‡įšŧįēŒé‚„原備äģŊīŧ", + "maintenance_restore_library_description": "æ­Ŗåœ¨é‚„åŽŸčŗ‡æ–™åēĢ", + "maintenance_restore_library_folder_has_files": "{folder} åĢ有 {count} å€‹čŗ‡æ–™å¤ž", + "maintenance_restore_library_folder_no_files": "{folder} įŧē少æĒ”æĄˆīŧ", + "maintenance_restore_library_folder_pass": "å¯čŽ€å–čˆ‡å¯Ģå…Ĩ", "maintenance_restore_library_folder_read_fail": "į„Ąæŗ•čŽ€å–", "maintenance_restore_library_folder_write_fail": "į„Ąæŗ•å¯Ģå…Ĩ", - "maintenance_restore_library_hint_missing_files": "可čƒŊéēå¤ąé‡čρæĒ”æĄˆ", + "maintenance_restore_library_hint_missing_files": "您可čƒŊéēå¤ąäē†é‡čρæĒ”æĄˆ", "maintenance_restore_library_hint_regenerate_later": "䚋垌可äģĨåœ¨č¨­åŽšé‡æ–°į”ĸį”Ÿ", - "maintenance_restore_library_hint_storage_template_missing_files": "æ­Ŗåœ¨äŊŋį”¨å„˛å­˜æ¨ĄæŋīŧŸäŊ å¯čƒŊæœ‰ä¸Ÿå¤ąįš„æĒ”æĄˆ", - "maintenance_restore_library_loading": "æ­Ŗåœ¨åŠ čŧ‰åŽŒæ•´æ€§æĒĸæŸĨ及啟į™ŧæŗ•åˆ†æžâ€Ļ", + "maintenance_restore_library_hint_storage_template_missing_files": "æ­Ŗåœ¨äŊŋį”¨å„˛å­˜į¯„æœŦīŧŸæ‚¨å¯čƒŊéēå¤ąäē†éƒ¨åˆ†æĒ”æĄˆ", + "maintenance_restore_library_loading": "æ­Ŗåœ¨čŧ‰å…Ĩ厌整性æĒĸæŸĨčˆ‡å•Ÿį™ŧåŧåˆ†æžâ€Ļ", "maintenance_task_backup": "æ­Ŗåœ¨åģēįĢ‹įžæœ‰čŗ‡æ–™åēĢįš„å‚™äģŊâ€Ļ", - "maintenance_task_migrations": "æ­Ŗåœ¨é€˛čĄŒčŗ‡æ–™åēĢ遡į§ģâ€Ļ", - "maintenance_task_restore": "æ­Ŗåœ¨åžžé¸æ“‡įš„å‚™äģŊ垊原â€Ļ", - "maintenance_task_rollback": "åžŠåŽŸå¤ąæ•—īŧŒæĸåžŠåˆ°äš‹å‰įš„å„˛å­˜â€Ļ", + "maintenance_task_migrations": "æ­Ŗåœ¨åŸˇčĄŒčŗ‡æ–™åēĢ遡į§ģâ€Ļ", + "maintenance_task_restore": "æ­Ŗåœ¨åžžé¸å–įš„å‚™äģŊé€˛čĄŒé‚„åŽŸâ€Ļ", + "maintenance_task_rollback": "é‚„åŽŸå¤ąæ•—īŧŒæ­Ŗåœ¨å›žæē¯č‡ŗé‚„原éģžâ€Ļ", "maintenance_title": "æšĢæ™‚ä¸å¯į”¨", "make": "čŖŊ造商", "manage_geolocation": "įŽĄį†äŊįŊŽ", - "manage_media_access_rationale": "æ­Ŗįĸē處ᐆ將躇į”ĸį§ģč‡ŗåžƒåœžæĄļä¸Ļ將å…ļ垞垃圞æĄļ中æĸ垊需čĻæ­¤č¨ąå¯ã€‚", + "manage_media_access_rationale": "需čĻæ­¤æŦŠé™æ‰čƒŊč™•į†é …į›Žį§ģč‡ŗåžƒåœžæĄļčˆ‡é‚„åŽŸįš„æ“äŊœã€‚", "manage_media_access_settings": "æ‰“é–‹č¨­åŽš", - "manage_media_access_subtitle": "å…č¨ąImmichæ‡‰į”¨į¨‹åēįŽĄį†å’Œį§ģ動åĒ’éĢ”æĒ”æĄˆã€‚", - "manage_media_access_title": "åĒ’éĢ”įŽĄį†č¨Ē問", - "manage_shared_links": "įŽĄį†å…ąäēĢ逪įĩ", - "manage_sharing_with_partners": "įŽĄį†čˆ‡čĻĒæœ‹åĨŊå‹įš„åˆ†äēĢ", + "manage_media_access_subtitle": "å…č¨ą Immich App įŽĄį†čˆ‡į§ģ動åĒ’éĢ”æĒ”æĄˆã€‚", + "manage_media_access_title": "åĒ’éĢ”įŽĄį†å­˜å–æŦŠé™", + "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_assets_in_bounds": "{count, plural, one {# åŧĩᅧቇ} other {# åŧĩᅧቇ}}", + "map_assets_in_bounds": "{count, plural, one {# åŧĩᛏቇ} other {# åŧĩᛏቇ}}", "map_cannot_get_user_location": "į„Ąæŗ•å–åž—äŊŋᔍ者äŊįŊŽ", "map_location_dialog_yes": "įĸē厚", "map_location_picker_page_use_location": "äŊŋį”¨æ­¤äŊįŊŽ", - "map_location_service_disabled_content": "需čĻå•Ÿį”¨åŽšäŊæœå‹™æ‰čƒŊéĄ¯į¤ēį›Žå‰äŊįŊŽį›¸é—œįš„é …į›Žã€‚čĻįžåœ¨å•Ÿį”¨å—ŽīŧŸ", + "map_location_service_disabled_content": "需čĻå•Ÿį”¨åŽšäŊæœå‹™æ‰čƒŊéĄ¯į¤ēæ‚¨į›Žå‰äŊįŊŽį›¸é—œįš„é …į›Žã€‚čĻįžåœ¨å•Ÿį”¨å—ŽīŧŸ", "map_location_service_disabled_title": "厚äŊæœå‹™åˇ˛åœį”¨", "map_marker_for_images": "在 {city}、{country} 拍攝åŊąåƒįš„地圖į¤ē記", "map_marker_with_image": "å¸ļ有åŊąåƒįš„地圖į¤ē記", - "map_no_location_permission_content": "需čρäŊįŊŽæŦŠé™æ‰čƒŊéĄ¯į¤ēčˆ‡į›Žå‰äŊįŊŽã€‚čĻįžåœ¨å°ąæŽˆäēˆäŊįŊŽæŦŠé™å—ŽīŧŸ", + "map_no_location_permission_content": "需čρäŊįŊŽæŦŠé™æ‰čƒŊéĄ¯į¤ēčˆ‡æ‚¨į›Žå‰äŊįŊŽį›¸é—œįš„é …į›Žã€‚čĻįžåœ¨å°ąæŽˆäēˆäŊįŊŽæŦŠé™å—ŽīŧŸ", "map_no_location_permission_title": "æ˛’æœ‰äŊįŊŽæŦŠé™", "map_settings": "åœ°åœ–č¨­åŽš", "map_settings_dark_mode": "æˇąč‰˛æ¨Ąåŧ", @@ -1497,7 +1503,7 @@ "map_settings_date_range_option_years": "{years} 嚴前", "map_settings_dialog_title": "åœ°åœ–č¨­åŽš", "map_settings_include_show_archived": "包æ‹Ŧåˇ˛å°å­˜é …į›Ž", - "map_settings_include_show_partners": "包åĢčĻĒæœ‹åĨŊ友", + "map_settings_include_show_partners": "包åĢčĻĒ友", "map_settings_only_show_favorites": "åƒ…éĄ¯į¤ēæ”ļč—įš„é …į›Ž", "map_settings_theme_settings": "地圖ä¸ģ題", "map_zoom_to_see_photos": "į¸Žå°äģĨæĒĸčĻ–é …į›Ž", @@ -1505,7 +1511,7 @@ "mark_as_read": "æ¨™č¨˜į‚ēåˇ˛čŽ€", "marked_all_as_read": "åˇ˛å…¨éƒ¨æ¨™č¨˜į‚ēåˇ˛čŽ€", "matches": "ᛏįŦĻ", - "matching_assets": "åŒšé…čŗ‡į”ĸ", + "matching_assets": "įŦĻåˆįš„é …į›Ž", "media_type": "åĒ’éĢ”éĄžåž‹", "memories": "回æ†ļ", "memories_all_caught_up": "åˇ˛å…¨éƒ¨įœ‹åŽŒ", @@ -1521,15 +1527,15 @@ "merge_people_limit": "一æŦĄæœ€å¤šéšģčƒŊ合äŊĩ 5 åŧĩ臉孔", "merge_people_prompt": "您čρ合äŊĩ這äē›äēēį‰Šå—ŽīŧŸæ­¤æ“äŊœį„Ąæŗ•æ’¤éŠˇã€‚", "merge_people_successfully": "成功合äŊĩäēēį‰Š", - "merged_people_count": "合äŊĩäē† {count, plural, one {# äŊäēēåŖĢ} other {# äŊäēēåŖĢ}}", + "merged_people_count": "厞合äŊĩ {count, plural, other {# äŊäēēį‰Š}}", "minimize": "最小化", "minute": "分", "minutes": "分鐘", "mirror_horizontal": "æ°´åšŗ", "mirror_vertical": "åž‚į›´", "missing": "排å…ĨæœĒ處ᐆ", - "mobile_app": "į§ģå‹•æ‡‰į”¨į¨‹åē", - "mobile_app_download_onboarding_note": "äŊŋᔍäģĨ下選項下čŧ‰é…åĨ—į§ģå‹•æ‡‰į”¨į¨‹åē", + "mobile_app": "čĄŒå‹•æ‡‰į”¨į¨‹åŧ", + "mobile_app_download_onboarding_note": "čĢ‹äŊŋᔍäģĨ下選項下čŧ‰éš¨é™„įš„čĄŒå‹•æ‡‰į”¨į¨‹åŧ", "model": "åž‹č™Ÿ", "month": "月", "monthly_title_text_date_format": "y MMMM", @@ -1539,26 +1545,26 @@ "move_off_locked_folder": "į§ģå‡ēéŽ–åŽšįš„čŗ‡æ–™å¤ž", "move_to": "į§ģ動到", "move_to_device_trash": "į§ģå‹•åˆ°čŖįŊŽįš„垃圞æĄļ", - "move_to_lock_folder_action_prompt": "{count} åˇ˛æ–°åĸžč‡ŗéŽ–åŽšįš„čŗ‡æ–™å¤žä¸­", - "move_to_locked_folder": "į§ģč‡ŗéŽ–åŽšįš„čŗ‡æ–™å¤ž", - "move_to_locked_folder_confirmation": "這äē›į…§į‰‡å’ŒåŊąį‰‡å°‡åžžæ‰€æœ‰į›¸į°ŋ中į§ģ除īŧŒä¸Ļåƒ…å¯åžžéŽ–åŽšįš„čŗ‡æ–™å¤žæĒĸčĻ–", + "move_to_lock_folder_action_prompt": "厞將 {count} å€‹é …į›Žæ–°åĸžč‡ŗã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤ž", + "move_to_locked_folder": "į§ģč‡ŗã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤ž", + "move_to_locked_folder_confirmation": "這äē›į›¸į‰‡čˆ‡åŊąį‰‡å°‡åžžæ‰€æœ‰į›¸į°ŋ中į§ģ除īŧŒä¸”僅čƒŊåžžã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žä¸­æĒĸčĻ–", "move_up": "向上į§ģ動", "moved_to_archive": "厞封存 {count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}}", "moved_to_library": "厞į§ģ動 {count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}} 臺ᛏį°ŋ", "moved_to_trash": "åˇ˛ä¸Ÿé€˛åžƒåœžæĄļ", - "multiselect_grid_edit_date_time_err_read_only": "į„Ąæŗ•įˇ¨čŧ¯å”¯čŽ€é …į›Žįš„æ—Ĩ期īŧŒį•Ĩ過", - "multiselect_grid_edit_gps_err_read_only": "į„Ąæŗ•įˇ¨čŧ¯å”¯čŽ€é …į›Žįš„äŊįŊŽčŗ‡č¨ŠīŧŒį•Ĩ過", + "multiselect_grid_edit_date_time_err_read_only": "å”¯čŽ€é …į›Žįš„æ—ĨæœŸį„Ąæŗ•įˇ¨čŧ¯īŧŒåˇ˛į•Ĩ過", + "multiselect_grid_edit_gps_err_read_only": "å”¯čŽ€é …į›Žįš„äŊįŊŽčŗ‡č¨Šį„Ąæŗ•ᎍčŧ¯īŧŒåˇ˛į•Ĩ過", "mute_memories": "éœéŸŗå›žæ†ļ", "my_albums": "æˆ‘įš„į›¸į°ŋ", "name": "åį¨ą", "name_or_nickname": "åį¨ąæˆ–æšąį¨ą", "name_required": "åį¨ąæ˜¯åŋ…åĄĢ項", "navigate": "導čˆĒ", - "navigate_to_time": "導čˆĒ到時間", - "network_requirement_photos_upload": "äŊŋį”¨čĄŒå‹•įļ˛čˇ¯æĩé‡å‚™äģŊᅧቇ", + "navigate_to_time": "莺čŊ‰č‡ŗæŒ‡åŽšæ™‚é–“", + "network_requirement_photos_upload": "äŊŋį”¨čĄŒå‹•įļ˛čˇ¯æĩé‡å‚™äģŊᛏቇ", "network_requirement_videos_upload": "äŊŋį”¨čĄŒå‹•įļ˛čˇ¯æĩé‡å‚™äģŊåŊąį‰‡", "network_requirements": "įļ˛čˇ¯čĻæą‚", - "network_requirements_updated": "įļ˛čˇ¯éœ€æą‚åˇ˛čŽŠæ›´īŧŒįžé‡č¨­å‚™äģŊäŊ‡åˆ—", + "network_requirements_updated": "įļ˛čˇ¯éœ€æą‚åˇ˛čŽŠæ›´īŧŒæ­Ŗåœ¨é‡č¨­å‚™äģŊäŊ‡åˆ—", "networking_settings": "įļ˛čˇ¯", "networking_subtitle": "įŽĄį†äŧ翜å™¨į̝éģžč¨­åޚ", "never": "æ°¸ä¸å¤ąæ•ˆ", @@ -1568,7 +1574,7 @@ "new_password": "新密įĸŧ", "new_person": "æ–°įš„äēēį‰Š", "new_pin_code": "新 PIN įĸŧ", - "new_pin_code_subtitle": "這是您įŦŦ一æŦĄå­˜å–éŽ–åŽšįš„čŗ‡æ–™å¤žã€‚åģēįĢ‹ PIN įĸŧäģĨ厉全存取此頁éĸ", + "new_pin_code_subtitle": "這是您įŦŦ一æŦĄå­˜å–ã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žã€‚čĢ‹åģēįĢ‹ PIN įĸŧäģĨ厉全存取此頁éĸ", "new_timeline": "新時間čģ¸", "new_update": "新更新", "new_user_created": "厞åģēįĢ‹æ–°äŊŋᔍ者", @@ -1577,43 +1583,42 @@ "next": "下一æ­Ĩ", "next_memory": "下一åŧĩ回æ†ļ", "no": "åĻ", - "no_actions_added": "尚æœĒæˇģ加äģģäŊ•操äŊœ", + "no_actions_added": "尚æœĒ新åĸžäģģäŊ•å‹•äŊœ", "no_albums_found": "į„Ąį›¸į°ŋ", - "no_albums_message": "åģēį̋ᛏį°ŋäž†æ•´į†į…§į‰‡å’ŒåŊąį‰‡", + "no_albums_message": "åģēį̋ᛏį°ŋäž†æ•´į†į›¸į‰‡å’ŒåŊąį‰‡", "no_albums_with_name_yet": "įœ‹äž†é‚„æ˛’æœ‰é€™å€‹åå­—įš„į›¸į°ŋ。", "no_albums_yet": "įœ‹äž†æ‚¨é‚„æ˛’æœ‰äģģäŊ•ᛏį°ŋ。", - "no_archived_assets_message": "å°‡į…§į‰‡å’ŒåŊąį‰‡å°å­˜īŧŒå°ąä¸æœƒéĄ¯į¤ēåœ¨ã€Œį…§į‰‡ã€ä¸­", - "no_assets_message": "æŒ‰é€™čŖĄä¸Šå‚ŗæ‚¨įš„įŦŦ一åŧĩᅧቇ", + "no_archived_assets_message": "å°‡į›¸į‰‡čˆ‡åŊąį‰‡å°å­˜åžŒīŧŒå°ąä¸æœƒéĄ¯į¤ēåœ¨ã€Œį›¸į‰‡ã€čĻ–åœ–ä¸­", + "no_assets_message": "æŒ‰é€™čŖĄä¸Šå‚ŗæ‚¨įš„įŦŦ一åŧĩᛏቇ", "no_assets_to_show": "į„Ąé …į›Žåą•į¤ē", "no_cast_devices_found": "扞不到 Google Cast čŖįŊŽ", - "no_checksum_local": "æ˛’æœ‰å¯į”¨įš„æ ĄéŠ—å’Œ - į„Ąæŗ•å–åž—æœŦæŠŸčŗ‡į”ĸ", - "no_checksum_remote": "æ˛’æœ‰å¯į”¨įš„æ ĄéŠ—å’Œ - į„Ąæŗ•å–åž—é į̝躇į”ĸ", - "no_configuration_needed": "į„Ąéœ€é…å¯˜", - "no_devices": "į„ĄæŽˆæŦŠč¨­å‚™", - "no_duplicates_found": "æ˛’į™ŧįžé‡č¤‡é …į›Žã€‚", + "no_checksum_local": "į„Ąå¯į”¨æ ĄéŠ—įĸŧ - į„Ąæŗ•å–åž—æœŦæŠŸé …į›Ž", + "no_checksum_remote": "į„Ąå¯į”¨æ ĄéŠ—įĸŧ - į„Ąæŗ•å–åž—é›˛įĢ¯é …į›Ž", + "no_configuration_needed": "į„Ąéœ€č¨­åŽš", + "no_devices": "į„ĄæŽˆæŦŠčŖįŊŽ", + "no_duplicates_found": "æœĒį™ŧįžäģģäŊ•é‡č¤‡é …į›Žã€‚", "no_exif_info_available": "æ˛’æœ‰å¯į”¨įš„ Exif čŗ‡č¨Š", - "no_explore_results_message": "ä¸Šå‚ŗæ›´å¤šį…§į‰‡äģĨ刊æŽĸį´ĸ。", + "no_explore_results_message": "ä¸Šå‚ŗæ›´å¤šį›¸į‰‡äž†æŽĸį´ĸæ‚¨įš„įč—ã€‚", "no_favorites_message": "加å…Ĩæ”ļ藏īŧŒåŠ é€Ÿå°‹æ‰žåŊąåƒ", - "no_filters_added": "尚æœĒæˇģåŠ į¯ŠæĒĸፋåŧ", - "no_libraries_message": "åģēįĢ‹å¤–éƒ¨åĒ’éĢ”åēĢäģĨæĒĸčĻ–æ‚¨įš„į…§į‰‡å’ŒåŊąį‰‡", - "no_local_assets_found": "æœĒæ‰žåˆ°å…ˇæœ‰æ­¤æ ĄéŠ—å’Œįš„æœŦæŠŸčŗ‡į”ĸ", + "no_filters_added": "尚æœĒ新åĸžäģģäŊ•į¯Šé¸å™¨", + "no_libraries_message": "åģēįĢ‹å¤–éƒ¨åĒ’éĢ”åēĢäģĨæĒĸčĻ–æ‚¨įš„į›¸į‰‡å’ŒåŊąį‰‡", + "no_local_assets_found": "æ‰žä¸åˆ°å…ˇæœ‰æ­¤æ ĄéŠ—įĸŧįš„æœŦæŠŸé …į›Ž", "no_location_set": "æœĒč¨­åŽšäŊįŊŽ", - "no_locked_photos_message": "éŽ–åŽšįš„čŗ‡æ–™å¤žä¸­įš„į…§į‰‡å’ŒåŊąį‰‡æœƒčĸĢ隱藏īŧŒį•ļæ‚¨į€čĻŊæˆ–æœå°‹į›¸į°ŋæ™‚ä¸æœƒéĄ¯į¤ē。", + "no_locked_photos_message": "ã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žä¸­įš„į›¸į‰‡čˆ‡åŊąį‰‡æœƒčĸĢ隱藏īŧŒä¸”不會å‡ēįžåœ¨į€čĻŊ或搜尋įĩæžœä¸­ã€‚", "no_name": "į„Ąå", "no_notifications": "æ˛’æœ‰é€šįŸĨ", "no_people_found": "扞不到įŦĻåˆįš„äēēį‰Š", "no_places": "æ˛’æœ‰åœ°éģž", - "no_remote_assets_found": "æœĒæ‰žåˆ°å…ˇæœ‰æ­¤æ ĄéŠ—å’Œįš„é į̝躇į”ĸ", + "no_remote_assets_found": "æ‰žä¸åˆ°å…ˇæœ‰æ­¤æ ĄéŠ—įĸŧįš„é›˛įĢ¯é …į›Ž", "no_results": "æ˛’æœ‰įĩæžœ", "no_results_description": "čŠĻčŠĻåŒįžŠčŠžæˆ–æ›´é€šį”¨įš„é—œéĩ字吧", - "no_shared_albums_message": "åģēį̋ᛏį°ŋ分äēĢį…§į‰‡å’ŒåŊąį‰‡", + "no_shared_albums_message": "åģēįĢ‹å…ąäēĢᛏį°ŋäģĨ分äēĢį›¸į‰‡čˆ‡åŊąį‰‡", "no_uploads_in_progress": "æ˛’æœ‰æ­Ŗåœ¨ä¸Šå‚ŗįš„é …į›Ž", "none": "į„Ą", "not_allowed": "ä¸å…č¨ą", "not_available": "ä¸éŠį”¨", "not_in_any_album": "不在äģģäŊ•ᛏį°ŋ中", - "not_selected": "æœĒ選擇", - "note_apply_storage_label_to_previously_uploaded assets": "*č¨ģīŧšåŸˇčĄŒåĨ—į”¨å„˛å­˜æ¨™įą¤å‰å…ˆä¸Šå‚ŗé …į›Ž", + "not_selected": "æœĒ選取", "notes": "提į¤ē", "nothing_here_yet": "æšĢį„Ąč¨Šæ¯", "notification_permission_dialog_content": "開啟通įŸĨīŧŒčĢ‹å‰åž€ã€Œč¨­åŽšã€īŧŒä¸Ļé¸æ“‡ã€Œå…č¨ąã€ã€‚", @@ -1624,8 +1629,8 @@ "notifications": "通įŸĨ", "notifications_setting_description": "įŽĄį†é€šįŸĨ", "oauth": "OAuth", - "obtainium_configurator": "Obtainium配寘器", - "obtainium_configurator_instructions": "äŊŋᔍObtainiumį›´æŽĨåžžImmich GitHubįš„į‰ˆæœŦåŽ‰čŖå’Œæ›´æ–°Androidæ‡‰į”¨į¨‹åēã€‚ å‰ĩåģē一個API金鑰ä¸Ļé¸æ“‡ä¸€å€‹čŽŠéĢ”äž†å‰ĩåģēæ‚¨įš„Obtainiumé…å¯˜é€Ŗįĩ", + "obtainium_configurator": "Obtainium č¨­åŽšå™¨", + "obtainium_configurator_instructions": "äŊŋᔍ Obtainium į›´æŽĨåžž Immich GitHub įš„į™ŧčĄŒį‰ˆæœŦåŽ‰čŖä¸Ļ更新 Android App。åģēįĢ‹ API 金鑰ä¸Ļé¸å–į‰ˆæœŦéĄžåž‹äģĨį”ĸį”Ÿæ‚¨įš„ Obtainium č¨­åŽšé€Ŗįĩ", "ocr": "OCR", "official_immich_resources": "厘斚 Immich čŗ‡æē", "offline": "é›ĸ᎚", @@ -1635,21 +1640,22 @@ "on_this_device": "åœ¨æ­¤čŖįŊŽ", "onboarding": "å…Ĩ門指南", "onboarding_locale_description": "é¸æ“‡æ‚¨æƒŗčĻéĄ¯į¤ēįš„čĒžč¨€ã€‚č¨­åŽšåŽŒæˆäš‹åžŒį”Ÿæ•ˆã€‚", - "onboarding_privacy_description": "äģĨ下īŧˆå¯é¸īŧ‰åŠŸčƒŊäģ°čŗ´å¤–部服務īŧŒå¯éš¨æ™‚åœ¨č¨­åŽšä¸­åœį”¨ã€‚", + "onboarding_privacy_description": "äģĨä¸‹é¸į”¨åŠŸčƒŊäģ°čŗ´å¤–部服務īŧŒæ‚¨å¯äģĨéš¨æ™‚åœ¨č¨­åŽšä¸­å°‡å…ļåœį”¨ã€‚", "onboarding_server_welcome_description": "čŽ“æˆ‘å€‘į‚ēæ‚¨įš„įŗģįĩąé€˛čĄŒä¸€äē›åŸēæœŦč¨­åŽšã€‚", - "onboarding_theme_description": "åšĢåŸˇčĄŒå€‹é̔遏艞åŊŠä¸ģéĄŒã€‚äš‹åžŒäšŸå¯äģĨåœ¨č¨­åŽšä¸­čŽŠæ›´ã€‚", + "onboarding_theme_description": "čĢ‹į‚ēæ‚¨įš„åŸˇčĄŒå€‹éĢ”é¸æ“‡č‰˛åŊŠä¸ģéĄŒã€‚æ‚¨į¨åžŒäģå¯åœ¨č¨­åŽšä¸­čŽŠæ›´ã€‚", "onboarding_user_welcome_description": "čŽ“æˆ‘å€‘é–‹å§‹å§īŧ", "onboarding_welcome_user": "æ­ĄčŋŽīŧŒ{user}", "online": "᎚䏊", "only_favorites": "åƒ…éĄ¯į¤ēåˇąæ”ļ藏", "open": "開啟", + "open_calendar": "打開æ—Ĩ曆", "open_in_map_view": "開啟地圖æĒĸčĻ–", "open_in_openstreetmap": "ᔍ OpenStreetMap 開啟", "open_the_search_filters": "é–‹å•Ÿæœå°‹į¯Šé¸å™¨", "options": "選項", "or": "或", "organize_into_albums": "æ•´į†æˆį›¸į°ŋ", - "organize_into_albums_description": "äŊŋį”¨į›Žå‰åŒæ­Ĩč¨­åŽšå°‡įžæœ‰į…§į‰‡æ”žå…Ĩᛏį°ŋ", + "organize_into_albums_description": "äŊŋį”¨į›Žå‰åŒæ­Ĩč¨­åŽšå°‡įžæœ‰į›¸į‰‡æ”žå…Ĩᛏį°ŋ", "organize_your_library": "æ•´į†æ‚¨įš„į›¸į°ŋ", "original": "原圖", "other": "å…ļäģ–", @@ -1657,22 +1663,22 @@ "other_entities": "å…ļäģ–é …į›Ž", "other_variables": "å…ļäģ–čŽŠæ•¸", "owned": "æˆ‘įš„", - "owner": "æ‰€æœ‰č€…", + "owner": "æ“æœ‰č€…", "page": "頁", - "partner": "čĻĒæœ‹åĨŊ友", - "partner_can_access": "{partner} 可äģĨ存取", - "partner_can_access_assets": "除äē†åˇ˛å°å­˜å’Œåˇ˛åˆĒ除䚋外īŧŒæ‚¨æ‰€æœ‰įš„į…§į‰‡å’ŒåŊąį‰‡", - "partner_can_access_location": "æ‚¨į…§į‰‡æ‹æ”įš„äŊįŊŽ", - "partner_list_user_photos": "{user} įš„į…§į‰‡", - "partner_list_view_all": "åą•į¤ē全部", - "partner_page_empty_message": "æ‚¨įš„į…§į‰‡å°šæœĒ與äģģäŊ•čĻĒæœ‹åĨŊå‹å…ąäēĢ。", - "partner_page_no_more_users": "į„Ąéœ€æ–°åĸžæ›´å¤šäŊŋᔍ者", - "partner_page_partner_add_failed": "新åĸžčĻĒæœ‹åĨŊå‹å¤ąæ•—", - "partner_page_select_partner": "選擇čĻĒæœ‹åĨŊ友", + "partner": "čĻĒ友", + "partner_can_access": "{partner} 可存取", + "partner_can_access_assets": "除äē†ã€Œåˇ˛å°å­˜ã€čˆ‡ã€Œåˇ˛åˆĒ除」外īŧŒæ‚¨æ‰€æœ‰įš„į›¸į‰‡čˆ‡åŊąį‰‡", + "partner_can_access_location": "į›¸į‰‡įš„æ‹æ”äŊįŊŽ", + "partner_list_user_photos": "{user} įš„į›¸į‰‡", + "partner_list_view_all": "æŸĨįœ‹å…¨éƒ¨", + "partner_page_empty_message": "æ‚¨įš„į›¸į‰‡å°šæœĒ與äģģäŊ•čĻĒå‹å…ąäēĢ。", + "partner_page_no_more_users": "åˇ˛į„Ąå¯æ–°åĸžįš„äŊŋᔍ者", + "partner_page_partner_add_failed": "čĻĒ友新åĸžå¤ąæ•—", + "partner_page_select_partner": "選擇čĻĒ友", "partner_page_shared_to_title": "å…ąäēĢįĩĻ", - "partner_page_stop_sharing_content": "{partner} å°‡į„Ąæŗ•å†å­˜å–æ‚¨įš„į…§į‰‡ã€‚", - "partner_sharing": "čĻĒæœ‹åĨŊ友分äēĢ", - "partners": "čĻĒæœ‹åĨŊ友", + "partner_page_stop_sharing_content": "{partner} å°‡į„Ąæŗ•å†å­˜å–æ‚¨įš„į›¸į‰‡ã€‚", + "partner_sharing": "čĻĒå‹å…ąäēĢ", + "partners": "čĻĒ友", "password": "密įĸŧ", "password_does_not_match": "密įĸŧä¸į›¸įŦĻ", "password_required": "需čρ坆įĸŧ", @@ -1689,9 +1695,9 @@ "paused": "厞æšĢ停", "pending": "åž…č™•į†", "people": "äēēį‰Š", - "people_edits_count": "ᎍčŧ¯äē† {count, plural, one {# äŊäēēåŖĢ} other {# äŊäēēåŖĢ}}", - "people_feature_description": "äģĨäēēį‰Šåˆ†éĄžį€čĻŊį…§į‰‡å’ŒåŊąį‰‡", - "people_selected": "{count, plural, one {# 個äēēåˇ˛é¸æ“‡} other {# 個äēēåˇ˛é¸æ“‡}}", + "people_edits_count": "厞ᎍčŧ¯ {count, plural, one {# äŊäēēį‰Š} other {# äŊäēēį‰Š}}", + "people_feature_description": "äģĨäēēį‰Šåˆ†éĄžį€čĻŊį›¸į‰‡å’ŒåŊąį‰‡", + "people_selected": "{count, plural, one {åˇ˛é¸å– # äŊäēēį‰Š} other {åˇ˛é¸å– # äŊäēēį‰Š}}", "people_sidebar_description": "在側邊æŦ„éĄ¯į¤ē「äēēį‰Šã€įš„é€Ŗįĩ", "permanent_deletion_warning": "永䚅åˆĒ除č­Ļ告", "permanent_deletion_warning_setting_description": "在永䚅åˆĒ除æĒ”æĄˆæ™‚éĄ¯į¤ēč­Ļ告", @@ -1709,40 +1715,40 @@ "permission_onboarding_permission_denied": "åĻ‚čρįšŧįēŒīŧŒčĢ‹å…č¨ą Immich å­˜å–į›¸į‰‡å’ŒåŊąį‰‡æŦŠé™ã€‚", "permission_onboarding_permission_granted": "åˇ˛å…č¨ąīŧä¸€åˆ‡å°ąįˇ’。", "permission_onboarding_permission_limited": "åĻ‚čρįšŧįēŒīŧŒčĢ‹å…č¨ą Immich 備äģŊå’ŒįŽĄį†æ‚¨įš„į›¸į°ŋæ”ļ藏īŧŒåœ¨č¨­åŽšä¸­æŽˆäēˆį›¸į‰‡å’ŒåŊąį‰‡æŦŠé™ã€‚", - "permission_onboarding_request": "Immich 需čρæŦŠé™æ‰čƒŊæĒĸčĻ–æ‚¨įš„į›¸į‰‡å’ŒįŸ­į‰‡ã€‚", + "permission_onboarding_request": "Immich 需čρæŦŠé™æ‰čƒŊæĒĸčĻ–æ‚¨įš„į›¸į‰‡čˆ‡åŊąį‰‡ã€‚", "person": "äēēį‰Š", "person_age_months": "{months, plural, one {# 個月} other {# 個月}}", "person_age_year_months": "1 åš´ {months, plural, one {# 個月} other {# 個月}}", "person_age_years": "{years, plural, other {# æ­˛}}", - "person_birthdate": "į”Ÿæ–ŧ {date}", + "person_birthdate": "å‡ēį”Ÿæ–ŧ {date}", "person_hidden": "{name}{hidden, select, true {īŧˆéšąč—īŧ‰} other {}}", - "person_recognized": "čĸĢčĒå¯įš„äēē", - "person_selected": "åˇ˛é¸æ“‡įš„äēē", - "photo_shared_all_users": "įœ‹äž†æ‚¨čˆ‡æ‰€æœ‰äŊŋį”¨č€…åˆ†äēĢäē†į…§į‰‡īŧŒæˆ–æ˛’æœ‰å…ļäģ–äŊŋį”¨č€…å¯äž›åˆ†äēĢ。", - "photos": "ᅧቇ", - "photos_and_videos": "į…§į‰‡åŠåŊąį‰‡", - "photos_count": "{count, plural, other {{count, number} åŧĩᅧቇ}}", - "photos_from_previous_years": "åž€åš´įš„į…§į‰‡", - "photos_only": "åĒå…č¨ąį…§į‰‡", + "person_recognized": "åˇ˛čž¨č­˜äēēį‰Š", + "person_selected": "åˇ˛é¸å–äēēį‰Š", + "photo_shared_all_users": "įœ‹äž†æ‚¨čˆ‡æ‰€æœ‰äŊŋį”¨č€…åˆ†äēĢäē†į›¸į‰‡īŧŒæˆ–æ˛’æœ‰å…ļäģ–äŊŋį”¨č€…å¯äž›åˆ†äēĢ。", + "photos": "ᛏቇ", + "photos_and_videos": "į›¸į‰‡åŠåŊąį‰‡", + "photos_count": "{count, plural, other {{count, number} åŧĩᛏቇ}}", + "photos_from_previous_years": "åž€åš´įš„į›¸į‰‡", + "photos_only": "åƒ…é™į›¸į‰‡", "pick_a_location": "選擇äŊįŊŽ", "pick_custom_range": "č‡ĒåŽšįžŠį¯„åœ", "pick_date_range": "選擇æ—ĨæœŸį¯„åœ", - "pin_code_changed_successfully": "čŽŠæ›´ PIN įĸŧ成功", - "pin_code_reset_successfully": "重設 PIN įĸŧ成功", - "pin_code_setup_successfully": "č¨­åŽš PIN įĸŧ成功", + "pin_code_changed_successfully": "PIN įĸŧčŽŠæ›´æˆåŠŸ", + "pin_code_reset_successfully": "PIN įĸŧé‡č¨­æˆåŠŸ", + "pin_code_setup_successfully": "PIN įĸŧč¨­åŽšæˆåŠŸ", "pin_verification": "PIN įĸŧ驗證", "place": "地éģž", "places": "地éģž", "places_count": "{count, plural, one {{count, number} 個地éģž} other {{count, number} 個地éģž}}", "play": "播攞", "play_memories": "播攞回æ†ļ", - "play_motion_photo": "æ’­æ”žå‹•æ…‹į…§į‰‡", + "play_motion_photo": "æ’­æ”žå‹•æ…‹į›¸į‰‡", "play_or_pause_video": "播攞或æšĢ停åŊąį‰‡", - "play_original_video": "播攞原始čĻ–é ģ", - "play_original_video_setting_description": "æ›´å–œæ­Ąæ’­æ”žåŽŸå§‹čĻ–é ģīŧŒč€Œä¸æ˜¯čŊ‰įĸŧčĻ–é ģ。 åĻ‚æžœåŽŸå§‹čŗ‡æēä¸į›¸åŽšīŧŒå‰‡å¯čƒŊį„Ąæŗ•æ­Ŗįĸēæ’­æ”žã€‚", - "play_transcoded_video": "播攞čŊ‰įĸŧčĻ–é ģ", + "play_original_video": "播攞原始åŊąį‰‡", + "play_original_video_setting_description": "å„Ē先播攞原始åŊąį‰‡č€ŒéžčŊ‰įĸŧåžŒįš„åŊąį‰‡ã€‚č‹ĨåŽŸå§‹é …į›Žä¸į›¸åŽšīŧŒå¯čƒŊį„Ąæŗ•æ­Ŗå¸¸æ’­æ”žã€‚", + "play_transcoded_video": "播攞čŊ‰įĸŧåŊąį‰‡", "please_auth_to_access": "čĢ‹é€˛čĄŒčēĢäģŊéŠ—č­‰æ‰čƒŊ存取", - "port": "åŸ åŖ", + "port": "逪æŽĨ埠", "preferences_settings_subtitle": "įŽĄį†æ‡‰į”¨į¨‹åŧååĨŊč¨­åŽš", "preferences_settings_title": "偏åĨŊč¨­åŽš", "preparing": "æē–å‚™", @@ -1752,72 +1758,72 @@ "previous_memory": "上一åŧĩ回æ†ļ", "previous_or_next_day": "前一夊/垌一夊", "previous_or_next_month": "上一個月/下一個月", - "previous_or_next_photo": "上一åŧĩᅧቇ/下一åŧĩᅧቇ", + "previous_or_next_photo": "上一åŧĩᛏቇ/下一åŧĩᛏቇ", "previous_or_next_year": "上一嚴/下一嚴", "primary": "éĻ–čρ", "privacy": "éšąį§", "profile": "叺æˆļč¨­åŽš", - "profile_drawer_app_logs": "æ—Ĩčnj", - "profile_drawer_client_server_up_to_date": "ᔍæˆļįĢ¯čˆ‡äŧ翜å™¨į̝éƒŊæ˜¯æœ€æ–°įš„", + "profile_drawer_app_logs": "į´€éŒ„", + "profile_drawer_client_server_up_to_date": "ᔍæˆļįĢ¯čˆ‡äŧ翜å™¨į‰ˆæœŦįš†į‚ē最新", "profile_drawer_github": "GitHub", - "profile_drawer_readonly_mode": "å”¯čŽ€æ¨Ąåŧåˇ˛é–‹å•Ÿã€‚čĢ‹é•ˇæŒ‰äŊŋį”¨č€…é ­åƒåœ–į¤ēäģĨįĩæŸã€‚", + "profile_drawer_readonly_mode": "å”¯čŽ€æ¨Ąåŧåˇ˛å•Ÿį”¨ã€‚é•ˇæŒ‰äŊŋᔍ者個äēē圖į¤ēåŗå¯é€€å‡ē。", "profile_image_of_user": "{user} įš„å€‹äēēčŗ‡æ–™åœ–į‰‡", "profile_picture_set": "åˇ˛č¨­åŽšå€‹äēēčŗ‡æ–™åœ–į‰‡ã€‚", "public_album": "å…Ŧ開ᛏį°ŋ", "public_share": "å…Ŧ開分äēĢ", - "purchase_account_info": "æ“č­ˇč€…", - "purchase_activated_subtitle": "感čŦæ‚¨å° Immich 及開æēčģŸéĢ”įš„æ”¯æ´", + "purchase_account_info": "æ”¯æŒč€…", + "purchase_activated_subtitle": "感čŦæ‚¨å° Immich 及開æēčģŸéĢ”įš„æ”¯æŒ", "purchase_activated_time": "æ–ŧ {date} å•Ÿį”¨", - "purchase_activated_title": "é‡‘é‘°æˆåŠŸå•Ÿį”¨äē†", + "purchase_activated_title": "é‡‘é‘°åˇ˛æˆåŠŸå•Ÿį”¨", "purchase_button_activate": "å•Ÿį”¨", - "purchase_button_buy": "čŗŧįŊŽ", - "purchase_button_buy_immich": "čŗŧįŊŽ Immich", + "purchase_button_buy": "čŗŧ財", + "purchase_button_buy_immich": "čŗŧ財 Immich", "purchase_button_never_show_again": "ä¸å†éĄ¯į¤ē", - "purchase_button_reminder": "過 30 夊再提醒我", + "purchase_button_reminder": "30 夊垌提醒我", "purchase_button_remove_key": "į§ģ除金鑰", - "purchase_button_select": "選這個", + "purchase_button_select": "選擇", "purchase_failed_activation": "å•Ÿį”¨å¤ąæ•—īŧčĢ‹æĒĸæŸĨæ‚¨įš„é›ģ子éƒĩäģļīŧŒå–åž—æ­Ŗįĸēįš„į”ĸ品金鑰īŧ", "purchase_individual_description_1": "針對個äēē", - "purchase_individual_description_2": "æ“č­ˇč€…į‹€æ…‹", + "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_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_description_1": "éŠį”¨æ–ŧ全äŧ翜å™¨", + "purchase_server_description_2": "æ”¯æŒč€…į‹€æ…‹", "purchase_server_title": "äŧ翜å™¨", - "purchase_settings_server_activated": "äŧ翜å™¨į”ĸå“é‡‘é‘°æ˜¯į”ąįŽĄį†č€…įŽĄį†įš„", - "query_asset_id": "æŸģčŠĸčŗ‡į”ĸ ID", + "purchase_settings_server_activated": "äŧ翜å™¨į”ĸå“é‡‘é‘°į”ąįŽĄį†å“ĄįŽĄį†", + "query_asset_id": "æŸĨčŠĸé …į›Ž ID", "queue_status": "處ᐆ䏭 {count}/{total}", - "rate_asset": "čŗ‡į”ĸčŠ•æ˜Ÿ", + "rate_asset": "é …į›ŽčŠ•åˆ†", "rating": "čŠ•æ˜Ÿ", "rating_clear": "æ¸…é™¤čŠ•į­‰", "rating_count": "{count, plural, other {# 星}}", "rating_description": "åœ¨čŗ‡č¨Šéĸæŋä¸­éĄ¯į¤ē EXIF čŠ•į­‰", "rating_set": "åˇ˛č¨­åŽšį‚ē{rating, plural, one {# 星} other {# 星}}", "reaction_options": "反應選項", - "read_changelog": "閱čĻŊčŽŠæ›´æ—Ĩčnj", - "readonly_mode_disabled": "å”¯čŽ€æ¨Ąåŧåˇ˛é—œé–‰", + "read_changelog": "閱čĻŊæ›´æ–°į´€éŒ„", + "readonly_mode_disabled": "å”¯čŽ€æ¨Ąåŧåˇ˛åœį”¨", "readonly_mode_enabled": "å”¯čŽ€æ¨Ąåŧåˇ˛é–‹å•Ÿ", "ready_for_upload": "厞æē–å‚™åĨŊä¸Šå‚ŗ", - "reassign": "重新指厚", - "reassigned_assets_to_existing_person": "厞將 {count, plural, other {# 個æĒ”æĄˆ}}重新指厚įĩĻ{name, select, null {įžæœ‰įš„äēē} other {{name}}}", - "reassigned_assets_to_new_person": "厞將 {count, plural, other {# 個æĒ”æĄˆ}}重新指厚įĩĻ一äŊæ–°äēēį‰Š", - "reassing_hint": "å°‡é¸åŽšįš„æĒ”æĄˆåˆ†é…įĩĻåˇąå­˜åœ¨įš„äēēį‰Š", + "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-albums": "最čŋ‘ᛏį°ŋ", + "recent_albums": "最čŋ‘ᛏį°ŋ", "recent_searches": "最čŋ‘æœå°‹é …į›Ž", - "recently_added": "čŋ‘期新åĸž", + "recently_added": "最čŋ‘æ–°åĸž", "recently_added_page_title": "最čŋ‘æ–°åĸž", "recently_taken": "最čŋ‘拍攝", "recently_taken_page_title": "最čŋ‘拍攝", @@ -1827,7 +1833,7 @@ "refresh_metadata": "é‡æ–°æ•´į†ä¸­įšŧčŗ‡æ–™", "refresh_thumbnails": "é‡æ–°æ•´į†į¸Žåœ–", "refreshed": "é‡æ–°æ•´į†åŽŒį•ĸ", - "refreshes_every_file": "é‡æ–°čŽ€å–įžæœ‰įš„æ‰€æœ‰æĒ”æĄˆå’Œæ–°æĒ”æĄˆ", + "refreshes_every_file": "é‡æ–°čŽ€å–æ‰€æœ‰įžæœ‰čˆ‡æ–°åĸžæĒ”æĄˆ", "refreshing_encoded_video": "æ­Ŗåœ¨é‡æ–°æ•´į†åˇ˛įˇ¨įĸŧįš„åŊąį‰‡", "refreshing_faces": "重整éĸéƒ¨čŗ‡æ–™ä¸­", "refreshing_metadata": "æ­Ŗåœ¨é‡æ–°æ•´į†ä¸­įšŧčŗ‡æ–™", @@ -1837,16 +1843,16 @@ "remote_media_summary": "遠į̝åĒ’éĢ”æ‘˜čρ", "remove": "į§ģ除", "remove_assets_album_confirmation": "įĸē厚čĻåžžį›¸į°ŋ中į§ģ除 {count, plural, other {# 個æĒ”æĄˆ}}嗎īŧŸ", - "remove_assets_shared_link_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_album_action_prompt": "åˇ˛åžžį›¸į°ŋ中į§ģ除äē† {count} å€‹é …į›Ž", "remove_from_favorites": "åžžæ”ļ藏中į§ģ除", - "remove_from_lock_folder_action_prompt": "åˇ˛åžžéŽ–åŽšįš„čŗ‡æ–™å¤žä¸­į§ģ除äē† {count} å€‹é …į›Ž", - "remove_from_locked_folder": "åžžéŽ–åŽšįš„čŗ‡æ–™å¤žä¸­į§ģ除", - "remove_from_locked_folder_confirmation": "您įĸē厚čρ將這äē›į…§į‰‡å’ŒåŊąį‰‡į§ģå‡ēéŽ–åŽšįš„čŗ‡æ–™å¤žå—ŽīŧŸé€™äē›å…§åŽšå°‡æœƒéĄ¯į¤ēåœ¨æ‚¨įš„į›¸į°ŋ中。", + "remove_from_lock_folder_action_prompt": "åˇ˛åžžã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žä¸­į§ģ除 {count} å€‹é …į›Ž", + "remove_from_locked_folder": "åžžã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žä¸­į§ģ除", + "remove_from_locked_folder_confirmation": "您įĸē厚čρ將這äē›į›¸į‰‡čˆ‡åŊąį‰‡į§ģå‡ēã€Œåˇ˛éŽ–åŽšã€čŗ‡æ–™å¤žå—ŽīŧŸį§ģå‡ē垌將會å‡ēįžåœ¨æ‚¨įš„åĒ’éĢ”åēĢ中。", "remove_from_shared_link": "åžžå…ąäēĢ逪įĩä¸­į§ģ除", "remove_memory": "į§ģ除記æ†ļ", "remove_photo_from_memory": "å°‡åœ–į‰‡åžžæ­¤č¨˜æ†ļ中į§ģ除", @@ -1858,11 +1864,11 @@ "removed_from_favorites": "åˇ˛åžžæ”ļ藏中į§ģ除", "removed_from_favorites_count": "厞į§ģ除æ”ļč—įš„ {count, plural, other {# å€‹é …į›Ž}}", "removed_memory": "厞į§ģ除記æ†ļ", - "removed_photo_from_memory": "åˇ˛åžžč¨˜æ†ļ中į§ģ除ᅧቇ", + "removed_photo_from_memory": "åˇ˛åžžč¨˜æ†ļ中į§ģ除ᛏቇ", "removed_tagged_assets": "厞į§ģ除 {count, plural, one {# 個æĒ”æĄˆ} other {# 個æĒ”æĄˆ}}įš„æ¨™įą¤", "rename": "攚名", "repair": "įŗžæ­Ŗ", - "repair_no_results_message": "æœĒčĸĢčŋŊčš¤åŠæœĒč™•į†įš„æĒ”æĄˆæœƒéĄ¯į¤ēåœ¨é€™čŖĄ", + "repair_no_results_message": "æœĒčĸĢčŋŊčš¤åŠéēå¤ąįš„æĒ”æĄˆæœƒéĄ¯į¤ēåœ¨é€™čŖĄ", "replace_with_upload": "į”¨ä¸Šå‚ŗįš„æĒ”æĄˆå–äģŖ", "repository": "å„˛å­˜åēĢ", "require_password": "需čρ坆įĸŧ", @@ -1872,19 +1878,19 @@ "reset_password": "é‡č¨­å¯†įĸŧ", "reset_people_visibility": "重設äēēį‰Šå¯čĻ‹æ€§", "reset_pin_code": "重設 PIN įĸŧ", - "reset_pin_code_description": "č‹Ĩåŋ˜č¨˜äē† PIN įĸŧīŧŒé–Ŗä¸‹å¯čĻæą‚įŗģįĩąäŧ翜å™¨įŽĄį†å“Ąį‚ēæ‚¨é‡č¨­", - "reset_pin_code_success": "é–Ŗä¸‹åˇ˛æˆåŠŸé‡č¨­ PIN įĸŧ", + "reset_pin_code_description": "č‹Ĩåŋ˜č¨˜ PIN įĸŧīŧŒæ‚¨å¯äģĨ聝įĩĄäŧ翜å™¨įŽĄį†å“Ąé€˛čĄŒé‡č¨­", + "reset_pin_code_success": "PIN įĸŧåˇ˛æˆåŠŸé‡č¨­", "reset_pin_code_with_password": "您可隨時äŊŋį”¨æ‚¨įš„å¯†įĸŧ來重設 PIN įĸŧ", "reset_sqlite": "重設 SQLite čŗ‡æ–™åēĢ", - "reset_sqlite_confirmation": "įĸē厚čĻé‡č¨­ SQLite čŗ‡æ–™åēĢ嗎īŧŸé–Ŗä¸‹éœ€į™ģå‡ēä¸Ļ重新į™ģå…Ĩ才čƒŊ重新同æ­Ĩčŗ‡æ–™", + "reset_sqlite_confirmation": "įĸē厚čĻé‡č¨­ SQLite čŗ‡æ–™åēĢ嗎īŧŸæ‚¨éœ€čρį™ģå‡ēä¸Ļ重新į™ģå…Ĩ才čƒŊ重新同æ­Ĩčŗ‡æ–™", "reset_sqlite_success": "åˇ˛æˆåŠŸé‡č¨­ SQLite čŗ‡æ–™åēĢ", - "reset_to_default": "é‡č¨­å›žé č¨­", - "resolution": "åˆ†čž¯įŽ‡", + "reset_to_default": "重設į‚ē預設å€ŧ", + "resolution": "č§ŖæžåēĻ", "resolve_duplicates": "č§Ŗæąē重複項", "resolved_all_duplicates": "厞觪æąēæ‰€æœ‰é‡č¤‡é …į›Ž", "restore": "還原", "restore_all": "全部還原", - "restore_trash_action_prompt": "åˇ˛åžžåžƒåœžæĄļ垊原äē† {count} å€‹é …į›Ž", + "restore_trash_action_prompt": "åˇ˛åžžåžƒåœžæĄļ還原 {count} å€‹é …į›Ž", "restore_user": "還原äŊŋᔍ者", "restored_asset": "åˇ˛é‚„åŽŸæĒ”æĄˆ", "resume": "įšŧįēŒ", @@ -1915,9 +1921,9 @@ "search_by_context": "äģĨ情åĸƒæœå°‹", "search_by_description": "äģĨ描čŋ°æœå°‹", "search_by_description_example": "åœ¨æ˛™åŖŠįš„åĨ行之æ—Ĩ", - "search_by_filename": "äģĨæĒ”名或副æĒ”名搜尋", + "search_by_filename": "䞝æĒ”名或副æĒ”名搜尋", "search_by_filename_example": "åĻ‚ IMG_1234.JPG 或 PNG", - "search_by_ocr": "通過OCR蒐į´ĸ", + "search_by_ocr": "透過OCR搜尋", "search_by_ocr_example": "æ‹ŋéĩ", "search_camera_lens_model": "蒐į´ĸéĄé ­åž‹č™Ÿ...", "search_camera_make": "æœå°‹į›¸æŠŸčŖŊ造商â€Ļ", @@ -1936,22 +1942,22 @@ "search_filter_location_title": "選擇äŊįŊŽ", "search_filter_media_type": "åĒ’éĢ”éĄžåž‹", "search_filter_media_type_title": "選擇åĒ’éĢ”éĄžåž‹", - "search_filter_ocr": "通過OCR蒐į´ĸ", + "search_filter_ocr": "透過OCR搜尋", "search_filter_people_title": "選擇äēēį‰Š", "search_filter_star_rating": "čŠ•åˆ†", "search_for": "搜尋", - "search_for_existing_person": "æœå°‹įžæœ‰įš„äēēį‰Š", + "search_for_existing_person": "æœå°‹įžæœ‰äēēį‰Š", "search_no_more_result": "į„Ąæ›´å¤šįĩæžœ", "search_no_people": "æ˛’æœ‰äē翉žåˆ°", "search_no_people_named": "æ˛’æœ‰åį‚ē「{name}ã€įš„äēēį‰Š", "search_no_result": "扞不到įĩæžœīŧŒčĢ‹å˜—čŠĻå…ļäģ–æœå°‹å­—čŠžæˆ–įĩ„合", "search_options": "搜尋選項", "search_page_categories": "類åˆĨ", - "search_page_motion_photos": "å‹•æ…‹į…§į‰‡", + "search_page_motion_photos": "å‹•æ…‹į›¸į‰‡", "search_page_no_objects": "æ‰žä¸åˆ°į‰Šäģļčŗ‡č¨Š", "search_page_no_places": "扞不到地éģžčŗ‡č¨Š", "search_page_screenshots": "čžĸåš•æˆĒ圖", - "search_page_search_photos_videos": "æœå°‹æ‚¨įš„į…§į‰‡čˆ‡åŊąį‰‡", + "search_page_search_photos_videos": "æœå°‹æ‚¨įš„į›¸į‰‡čˆ‡åŊąį‰‡", "search_page_selfies": "č‡Ē拍", "search_page_things": "äē‹į‰Š", "search_page_view_all_button": "æĒĸčĻ–å…¨éƒ¨", @@ -1968,35 +1974,35 @@ "search_tags": "æœå°‹æ¨™įą¤...", "search_timezone": "搜尋時區â€Ļ", "search_type": "æœå°‹éĄžåž‹", - "search_your_photos": "æœå°‹į…§į‰‡", + "search_your_photos": "æœå°‹į›¸į‰‡", "searching_locales": "搜尋區域â€Ļ", "second": "į§’", "see_all_people": "æĒĸčĻ–æ‰€æœ‰äēēį‰Š", "select": "選擇", - "select_album": "é¸æ“‡į›¸å†Œ", + "select_album": "é¸æ“‡į›¸į°ŋ", "select_album_cover": "é¸æ“‡į›¸į°ŋ封éĸ", - "select_albums": "é¸æ“‡į›¸å†Œ", + "select_albums": "é¸æ“‡į›¸į°ŋ", "select_all": "選擇全部", "select_all_duplicates": "äŋį•™æ‰€æœ‰é‡č¤‡é …", "select_all_in": "選擇在 {group} ä¸­įš„æ‰€æœ‰é …į›Ž", - "select_avatar_color": "選擇個äēēčŗ‡æ–™åœ–į‰‡éĄč‰˛", + "select_avatar_color": "選擇個äēē圖į¤ē顏色", "select_count": "{count, plural, one {選擇 #} other {選擇 #}}", "select_cutoff_date": "選擇æˆĒæ­ĸæ—Ĩ期", "select_face": "é¸æ“‡č‡‰å­”", - "select_featured_photo": "é¸æ“‡į‰šč‰˛į…§į‰‡", + "select_featured_photo": "é¸å–į˛žé¸į›¸į‰‡", "select_from_computer": "åžžé›ģč…Ļ中選取", "select_keep_all": "全部äŋį•™", "select_library_owner": "é¸æ“‡į›¸į°ŋæ“æœ‰č€…", "select_new_face": "é¸æ“‡æ–°č‡‰å­”", - "select_people": "選擇äēēå“Ą", - "select_person": "選擇äēēå“Ą", + "select_people": "選擇äēēį‰Š", + "select_person": "選取äēēį‰Š", "select_person_to_tag": "選擇čĻæ¨™č¨˜įš„äēēį‰Š", - "select_photos": "遏ᅧቇ", + "select_photos": "遏ᛏቇ", "select_trash_all": "全部åˆĒ除", - "select_user_for_sharing_page_err_album": "新åĸžį›¸į°ŋå¤ąæ•—", - "selected": "åˇ˛é¸æ“‡", - "selected_count": "{count, plural, other {選äē† # 項}}", - "selected_gps_coordinates": "é¸åŽšįš„ GPS åē§æ¨™", + "select_user_for_sharing_page_err_album": "åģēį̋ᛏį°ŋå¤ąæ•—", + "selected": "åˇ˛é¸å–", + "selected_count": "{count, plural, other {åˇ˛é¸å– # 項}}", + "selected_gps_coordinates": "é¸å–įš„ GPS åē§æ¨™", "send_message": "å‚ŗč¨Šæ¯", "send_welcome_email": "å‚ŗé€æ­ĄčŋŽé›ģ子éƒĩäģļ", "server_endpoint": "äŧ翜å™¨į̝éģž", @@ -2005,23 +2011,23 @@ "server_offline": "äŧ翜å™¨åˇ˛é›ĸ᎚", "server_online": "äŧ翜å™¨åˇ˛ä¸Šįˇš", "server_privacy": "äŧ翜å™¨éšąį§", - "server_restarting_description": "此頁éĸ將įĢ‹åŗé‡įšĒ。", - "server_restarting_title": "æœå‹™å™¨æ­Ŗåœ¨é‡æ–°å•Ÿå‹•", + "server_restarting_description": "此頁éĸå°‡åœ¨į¨åžŒč‡Ēå‹•é‡æ–°æ•´į†ã€‚", + "server_restarting_title": "äŧ翜å™¨æ­Ŗåœ¨é‡æ–°å•Ÿå‹•", "server_stats": "äŧ翜å™¨įĩąč¨ˆ", - "server_update_available": "æœå‹™å™¨æ›´æ–°å¯į”¨", + "server_update_available": "åˇ˛æœ‰å¯į”¨įš„äŧ翜å™¨æ›´æ–°", "server_version": "į›Žå‰į‰ˆæœŦ", "set": "č¨­åŽš", "set_as_album_cover": "設į‚ēᛏį°ŋ封éĸ", - "set_as_featured_photo": "設į‚ēį‰šč‰˛į…§į‰‡", + "set_as_featured_photo": "設į‚ēį˛žé¸į›¸į‰‡", "set_as_profile_picture": "設į‚ē個äēēčŗ‡æ–™åœ–į‰‡", "set_date_of_birth": "č¨­åŽšå‡ēį”Ÿæ—Ĩ期", "set_profile_picture": "č¨­åŽšå€‹äēēčŗ‡æ–™åœ–į‰‡", "set_slideshow_to_fullscreen": "äģĨ全čžĸ嚕攞映åšģį‡ˆį‰‡", "set_stack_primary_asset": "č¨­åŽšå †į–Šįš„éĻ–čĻé …į›Ž", - "setting_image_viewer_help": "čŠŗį´°čŗ‡č¨ŠæĒĸčϖ噍éĻ–å…ˆčŧ‰å…Ĩå°į¸Žåœ–īŧŒį„ļ垌čŧ‰å…Ĩä¸­į­‰å¤§å°įš„é čĻŊ圖īŧˆč‹Ĩå•Ÿį”¨īŧ‰īŧŒæœ€åžŒčŧ‰å…ĨåŽŸå§‹åœ–į‰‡ã€‚", - "setting_image_viewer_original_subtitle": "å•Ÿį”¨äģĨčŧ‰å…Ĩ原圖īŧŒåœį”¨äģĨæ¸›å°‘čŗ‡æ–™äŊŋį”¨é‡īŧˆåŒ…æ‹Ŧįļ˛čˇ¯å’ŒčŖįŊŽåŋĢ取īŧ‰ã€‚", + "setting_image_viewer_help": "čŠŗį´°čŗ‡č¨ŠæĒĸčĻ–å™¨æœƒäžåēčŧ‰å…Ĩå°åž‹į¸Žåœ–ã€ä¸­į­‰å°ē寸預čĻŊ圖īŧˆč‹Ĩå•Ÿį”¨īŧ‰īŧŒæœ€åžŒčŧ‰å…ĨåŽŸå§‹į›¸į‰‡ã€‚", + "setting_image_viewer_original_subtitle": "å•Ÿį”¨äģĨčŧ‰å…ĨåŽŸå§‹å…¨č§ŖæžåēĻåœ–į‰‡īŧˆæĒ”æĄˆčŧƒå¤§īŧīŧ‰ã€‚åœį”¨äģĨ減少æĩé‡äŊŋᔍīŧˆåŒ…æ‹Ŧįļ˛čˇ¯å‚ŗčŧ¸čˆ‡čŖįŊŽåŋĢ取īŧ‰ã€‚", "setting_image_viewer_original_title": "čŧ‰å…Ĩ原圖", - "setting_image_viewer_preview_subtitle": "å•Ÿį”¨äģĨčŧ‰å…Ĩ䏭ᭉ品čŗĒįš„åœ–į‰‡īŧŒåœį”¨äģĨčŧ‰å…ĨåŽŸåœ–æˆ–į¸Žåœ–ã€‚", + "setting_image_viewer_preview_subtitle": "å•Ÿį”¨äģĨčŧ‰å…Ĩä¸­į­‰č§ŖæžåēĻåœ–į‰‡ã€‚åœį”¨å‰‡į›´æŽĨčŧ‰å…Ĩ原圖或僅äŊŋį”¨į¸Žåœ–ã€‚", "setting_image_viewer_preview_title": "čŧ‰å…Ĩ預čĻŊ圖", "setting_image_viewer_title": "åœ–į‰‡", "setting_languages_apply": "åĨ—ᔍ", @@ -2037,10 +2043,10 @@ "setting_notifications_subtitle": "čĒŋ整通įŸĨ選項", "setting_notifications_total_progress_subtitle": "į¸ŊéĢ”ä¸Šå‚ŗé€˛åēĻ (åˇ˛åŽŒæˆ/į¸Ŋ計)", "setting_notifications_total_progress_title": "éĄ¯į¤ēčƒŒæ™¯å‚™äģŊį¸Ŋ進åēĻ", - "setting_video_viewer_auto_play_subtitle": "打開čĻ–é ģ時č‡Ē動開始播攞", - "setting_video_viewer_auto_play_title": "č‡Ē動播攞čĻ–é ģ", - "setting_video_viewer_looping_title": "čŋ´åœˆæ’­æ”ž", - "setting_video_viewer_original_video_subtitle": "åžžäŧ翜å™¨ä¸˛æĩåŊąį‰‡æ™‚īŧŒå„Ē先播攞原始į•ĢčŗĒīŧˆåŗäŊŋ有čŊ‰æĒ”įš„į‰ˆæœŦå¯į”¨īŧ‰ã€‚這可čƒŊæœƒå°Žč‡´æ’­æ”žæ™‚å‡ēįžįˇŠčĄæƒ…æŗã€‚č‹ĨåŊąį‰‡åˇ˛å„˛å­˜åœ¨æœŦ抟īŧŒå‰‡ä¸€åž‹äģĨ原始į•ĢčŗĒ播攞īŧŒčˆ‡æ­¤č¨­åŽšį„Ąé—œã€‚", + "setting_video_viewer_auto_play_subtitle": "開啟åŊąį‰‡æ™‚č‡Ē動開始播攞", + "setting_video_viewer_auto_play_title": "č‡Ē動播攞åŊąį‰‡", + "setting_video_viewer_looping_title": "åžĒį’°æ’­æ”ž", + "setting_video_viewer_original_video_subtitle": "åžžäŧ翜å™¨ä¸˛æĩåŊąį‰‡æ™‚īŧŒåŗäŊŋåˇ˛æœ‰čŊ‰įĸŧį‰ˆæœŦīŧŒäģå„Ē先播攞原始į•ĢčŗĒ。這可čƒŊæœƒå°Žč‡´įˇŠčĄã€‚å„˛å­˜æ–ŧæœŦæŠŸįš„åŊąį‰‡å‰‡ä¸€åž‹äģĨ原始į•ĢčŗĒ播攞īŧŒä¸å—æ­¤č¨­åޚåŊąéŸŋ。", "setting_video_viewer_original_video_title": "一型播攞原始åŊąį‰‡", "settings": "č¨­åŽš", "settings_require_restart": "čĢ‹é‡å•Ÿ Immich äģĨäŊŋč¨­åŽšį”Ÿæ•ˆ", @@ -2049,11 +2055,11 @@ "share": "分äēĢ", "share_action_prompt": "åˇ˛åˆ†äēĢäē† {count} å€‹é …į›Ž", "share_add_photos": "新åĸžé …į›Ž", - "share_assets_selected": "{count} åˇ˛é¸æ“‡", + "share_assets_selected": "åˇ˛é¸å– {count} 項", "share_dialog_preparing": "æ­Ŗåœ¨æē–å‚™...", "share_link": "分äēĢ逪įĩ", "shared": "å…ąäēĢ", - "shared_album_activities_input_disable": "åˇ˛åœį”¨čŠ•čĢ–", + "shared_album_activities_input_disable": "į•™č¨€åŠŸčƒŊåˇ˛åœį”¨", "shared_album_activity_remove_content": "您įĸē厚čρåˆĒ除此æ´ģ動嗎īŧŸ", "shared_album_activity_remove_title": "åˆĒ除æ´ģ動", "shared_album_section_people_action_error": "įĩæŸ/åˆĒ除ᛏį°ŋå¤ąæ•—", @@ -2061,14 +2067,14 @@ "shared_album_section_people_action_remove_user": "åžžį›¸į°ŋ中åˆĒ除äŊŋᔍ者", "shared_album_section_people_title": "äēēį‰Š", "shared_by": "å…ąäēĢč‡Ē", - "shared_by_user": "į”ą {user} 分äēĢ", - "shared_by_you": "į”ąæ‚¨åˆ†äēĢ", - "shared_from_partner": "來č‡Ē {partner} įš„į…§į‰‡", + "shared_by_user": "į”ą {user} å…ąäēĢ", + "shared_by_you": "į”ąæ‚¨å…ąäēĢ", + "shared_from_partner": "來č‡Ē {partner} įš„į›¸į‰‡", "shared_intent_upload_button_progress_text": "{current} / {total} åˇ˛ä¸Šå‚ŗ", - "shared_link_app_bar_title": "å…ąäēĢ逪įĩ", - "shared_link_clipboard_copied_massage": "複čŖŊ到å‰Ēč˛ŧį°ŋ", + "shared_link_app_bar_title": "分äēĢ逪įĩ", + "shared_link_clipboard_copied_massage": "厞複čŖŊ到å‰Ēč˛ŧį°ŋ", "shared_link_clipboard_text": "逪įĩīŧš {link}\n密įĸŧīŧš {password}", - "shared_link_create_error": "新åĸžå…ąäēĢ逪įĩæ™‚į™ŧį”ŸéŒ¯čǤ", + "shared_link_create_error": "åģēįĢ‹åˆ†äēĢ逪įĩæ™‚į™ŧį”ŸéŒ¯čǤ", "shared_link_custom_url_description": "äŊŋᔍč‡Ē訂 URL", "shared_link_edit_description_hint": "ᎍčŧ¯å…ąäēĢæčŋ°", "shared_link_edit_expire_after_option_day": "1 夊", @@ -2093,22 +2099,22 @@ "shared_link_expires_seconds": "將在 {count} į§’åžŒéŽæœŸ", "shared_link_individual_shared": "個äēēå…ąäēĢ", "shared_link_info_chip_metadata": "EXIF", - "shared_link_manage_links": "įŽĄį†å…ąäēĢ逪įĩ", - "shared_link_options": "å…ąäēĢ逪įĩé¸é …", - "shared_link_password_description": "čĻæą‚åœ¨å­˜å–æ­¤é€Ŗįĩæ™‚提䞛密įĸŧ", - "shared_links": "å…ąäēĢ逪įĩ", - "shared_links_description": "äģĨ逪įĩåˆ†äēĢį…§į‰‡å’ŒåŊąį‰‡", - "shared_photos_and_videos_count": "{assetCount, plural, other {åˇ˛åˆ†äēĢ # åŧĩį…§į‰‡åŠåŊąį‰‡ã€‚}}", + "shared_link_manage_links": "įŽĄį†åˆ†äēĢ逪įĩ", + "shared_link_options": "分äēĢ逪įĩé¸é …", + "shared_link_password_description": "存取此分äēĢ逪įĩæ™‚čĻæą‚å¯†įĸŧ", + "shared_links": "分äēĢ逪įĩ", + "shared_links_description": "äģĨ逪įĩåˆ†äēĢį›¸į‰‡å’ŒåŊąį‰‡", + "shared_photos_and_videos_count": "{assetCount, plural, other {åˇ˛åˆ†äēĢ # åŧĩį›¸į‰‡åŠåŊąį‰‡ã€‚}}", "shared_with_me": "čˆ‡æˆ‘å…ąäēĢ", "shared_with_partner": "與 {partner} å…ąäēĢ", "sharing": "å…ąäēĢ", "sharing_enter_password": "čρæĒĸčĻ–æ­¤é éĸčĢ‹čŧ¸å…Ĩ密įĸŧ。", "sharing_page_album": "å…ąäēĢᛏį°ŋ", - "sharing_page_description": "新åĸžå…ąäēĢᛏį°ŋäģĨ與įļ˛čˇ¯ä¸­įš„äēēå…ąäēĢį…§į‰‡å’ŒįŸ­į‰‡ã€‚", + "sharing_page_description": "åģēįĢ‹å…ąäēĢᛏį°ŋīŧŒčˆ‡æ‚¨įļ˛čˇ¯ä¸­įš„æˆå“Ąåˆ†äēĢį›¸į‰‡čˆ‡åŊąį‰‡ã€‚", "sharing_page_empty_list": "įŠēį™Ŋ清喎", "sharing_sidebar_description": "在側邊æŦ„éĄ¯į¤ēå…ąäēĢ逪įĩ", - "sharing_silver_appbar_create_shared_album": "新åĸžå…ąäēĢᛏį°ŋ", - "sharing_silver_appbar_share_partner": "å…ąäēĢįĩĻčĻĒæœ‹åĨŊ友", + "sharing_silver_appbar_create_shared_album": "åģēįĢ‹å…ąäēĢᛏį°ŋ", + "sharing_silver_appbar_share_partner": "與čĻĒå‹å…ąäēĢ", "shift_to_permanent_delete": "按 ⇧ 永䚅åˆĒ除æĒ”æĄˆ", "show_album_options": "éĄ¯į¤ēᛏį°ŋ選項", "show_albums": "éĄ¯į¤ēᛏį°ŋ", @@ -2118,7 +2124,7 @@ "show_gallery": "éĄ¯į¤ēį•ĢåģŠ", "show_hidden_people": "éĄ¯į¤ēéšąč—įš„äēēį‰Š", "show_in_timeline": "在時間čģ¸ä¸­éĄ¯į¤ē", - "show_in_timeline_setting_description": "åœ¨æ‚¨įš„æ™‚é–“čģ¸ä¸­éĄ¯į¤ē這äŊäŊŋį”¨č€…įš„į…§į‰‡å’ŒåŊąį‰‡", + "show_in_timeline_setting_description": "åœ¨æ‚¨įš„æ™‚é–“čģ¸ä¸­éĄ¯į¤ē這äŊäŊŋį”¨č€…įš„į›¸į‰‡å’ŒåŊąį‰‡", "show_keyboard_shortcuts": "éĄ¯į¤ēéĩᛤåŋĢæˇéĩ", "show_metadata": "éĄ¯į¤ē中įšŧčŗ‡æ–™", "show_or_hide_info": "éĄ¯į¤ēæˆ–éšąč—čŗ‡č¨Š", @@ -2127,11 +2133,11 @@ "show_progress_bar": "éĄ¯į¤ē進åēĻæĸ", "show_schema": "éĄ¯į¤ēæžļ構", "show_search_options": "éĄ¯į¤ē搜尋選項", - "show_shared_links": "éĄ¯į¤ēå…ąäēĢ逪įĩ", + "show_shared_links": "éĄ¯į¤ē分äēĢ逪įĩ", "show_slideshow_transition": "éĄ¯į¤ēåšģį‡ˆį‰‡čŊ‰å ´", - "show_supporter_badge": "æ“č­ˇč€…åžŊįĢ ", - "show_supporter_badge_description": "éĄ¯į¤ēæ“č­ˇč€…åžŊįĢ ", - "show_text_recognition": "éĄ¯į¤ēæ–‡å­—č­˜åˆĨ", + "show_supporter_badge": "æ”¯æŒč€…åžŊįĢ ", + "show_supporter_badge_description": "éĄ¯į¤ēæ”¯æŒč€…åžŊįĢ ", + "show_text_recognition": "éĄ¯į¤ēæ–‡å­—čž¨č­˜", "show_text_search_menu": "éĄ¯į¤ēæ–‡å­—č’į´ĸ選喎", "shuffle": "隨抟排åē", "sidebar": "側邊æŦ„", @@ -2151,16 +2157,16 @@ "sort_items": "é …į›Žæ•¸é‡", "sort_modified": "æ—ĨæœŸåˇ˛äŋŽæ”š", "sort_newest": "æœ€æ–°įš„į›¸į‰‡", - "sort_oldest": "æœ€čˆŠįš„į…§į‰‡", - "sort_people_by_similarity": "æŒ‰į›¸äŧŧåēϿޒåēäēēå“Ą", - "sort_recent": "æœ€æ–°įš„į…§į‰‡", + "sort_oldest": "æœ€čˆŠįš„į›¸į‰‡", + "sort_people_by_similarity": "äžį›¸äŧŧåēϿޒåēäēēį‰Š", + "sort_recent": "æœ€æ–°įš„į›¸į‰‡", "sort_title": "æ¨™éĄŒ", "source": "來æē", "stack": "å †į–Š", "stack_action_prompt": "åˇ˛å †į–Šäē†{count} å€‹é …į›Ž", "stack_duplicates": "å †į–Šé‡č¤‡é …į›Ž", - "stack_select_one_photo": "į‚ēå †į–Šé¸ä¸€åŧĩä¸ģčρᅧቇ", - "stack_selected_photos": "å †į–Šæ‰€é¸įš„į…§į‰‡", + "stack_select_one_photo": "į‚ēå †į–Šé¸ä¸€åŧĩä¸ģčρᛏቇ", + "stack_selected_photos": "å †į–Šé¸å–įš„į›¸į‰‡", "stacked_assets_count": "åˇ˛å †į–Š {count, plural, one {# 個æĒ”æĄˆ} other {# 個æĒ”æĄˆ}}", "stacktrace": "å †į–ŠčŋŊ蚤", "start": "開始", @@ -2169,10 +2175,10 @@ "state": "地區", "status": "į‹€æ…‹", "stop_casting": "停æ­ĸ投攞", - "stop_motion_photo": "停æ­ĸå‹•æ…‹į…§į‰‡", - "stop_photo_sharing": "čρ停æ­ĸ分äēĢæ‚¨įš„į…§į‰‡å—ŽīŧŸ", - "stop_photo_sharing_description": "{partner} å°‡į„Ąæŗ•å†å­˜å–æ‚¨įš„į…§į‰‡ã€‚", - "stop_sharing_photos_with_user": "停æ­ĸčˆ‡æ­¤äŊŋį”¨č€…å…ąäēĢæ‚¨įš„ᅧቇ", + "stop_motion_photo": "停æ­ĸå‹•æ…‹į›¸į‰‡", + "stop_photo_sharing": "čρ停æ­ĸ分äēĢæ‚¨įš„į›¸į‰‡å—ŽīŧŸ", + "stop_photo_sharing_description": "{partner} å°‡į„Ąæŗ•å†å­˜å–æ‚¨įš„į›¸į‰‡ã€‚", + "stop_sharing_photos_with_user": "停æ­ĸčˆ‡æ­¤äŊŋį”¨č€…å…ąäēĢæ‚¨įš„ᛏቇ", "storage": "å„˛å­˜įŠē間", "storage_label": "å„˛å­˜æ¨™įą¤", "storage_quota": "å„˛å­˜įŠē間", @@ -2184,19 +2190,20 @@ "support": "支援", "support_and_feedback": "æ”¯æ´čˆ‡å›žéĨ‹", "support_third_party_description": "æ‚¨åŽ‰čŖįš„ Immich æ˜¯į”ąįŦŦä¸‰æ–šæ‰“åŒ…įš„ã€‚æ‚¨é‡åˆ°įš„å•éĄŒå¯čƒŊæ˜¯čŠ˛åĨ—äģļé€ æˆįš„īŧŒæ‰€äģĨčĢ‹å…ˆäŊŋᔍ䏋éĸįš„é€Ŗįĩå‘äģ–們提å‡ēå•éĄŒã€‚", + "supporter": "æ”¯æŒč€…", "swap_merge_direction": "ä礿›åˆäŊĩ斚向", "sync": "同æ­Ĩ", "sync_albums": "同æ­Ĩᛏį°ŋ", - "sync_albums_manual_subtitle": "å°‡æ‰€æœ‰ä¸Šå‚ŗįš„įŸ­į‰‡å’Œį…§į‰‡åŒæ­Ĩåˆ°é¸åŽšįš„å‚™äģŊᛏį°ŋ", + "sync_albums_manual_subtitle": "å°‡æ‰€æœ‰ä¸Šå‚ŗįš„åŊąį‰‡čˆ‡į›¸į‰‡åŒæ­Ĩč‡ŗé¸å–įš„å‚™äģŊᛏį°ŋ", "sync_local": "同æ­ĨæœŦ抟", "sync_remote": "同æ­Ĩ遠į̝", "sync_status": "同æ­Ĩį‹€æ…‹", "sync_status_subtitle": "æĒĸčĻ–å’ŒįŽĄį†åŒæ­Ĩįŗģįĩą", - "sync_upload_album_setting_subtitle": "新åĸžį…§į‰‡å’ŒįŸ­į‰‡ä¸Ļä¸Šå‚ŗåˆ° Immich ä¸Šįš„é¸åŽšį›¸į°ŋ中", + "sync_upload_album_setting_subtitle": "åģēįĢ‹ä¸Ļä¸Šå‚ŗį›¸į‰‡čˆ‡åŊąį‰‡č‡ŗ Immich ä¸Šé¸å–įš„į›¸į°ŋ", "tag": "æ¨™įą¤", "tag_assets": "æ¨™č¨˜æĒ”æĄˆ", "tag_created": "厞åģēįĢ‹æ¨™įą¤īŧš{tag}", - "tag_feature_description": "äģĨ邏čŧ¯æ¨™č¨˜čĻæ—¨åˆ†éĄžį€čĻŊį…§į‰‡å’ŒåŊąį‰‡", + "tag_feature_description": "äģĨ邏čŧ¯æ¨™č¨˜čĻæ—¨åˆ†éĄžį€čĻŊį›¸į‰‡å’ŒåŊąį‰‡", "tag_not_found_question": "æ‰žä¸åˆ°æ¨™įą¤īŧŸåģēįĢ‹æ–°æ¨™įą¤ã€‚", "tag_people": "æ¨™įą¤äēēį‰Š", "tag_updated": "åˇ˛æ›´æ–°æ¨™įą¤īŧš{tag}", @@ -2204,7 +2211,7 @@ "tags": "æ¨™įą¤", "tap_to_run_job": "éģžé¸äģĨ進行äŊœæĨ­", "template": "æ¨Ąæŋ", - "text_recognition": "æ–‡å­—č­˜åˆĨ", + "text_recognition": "æ–‡å­—čž¨č­˜", "theme": "ä¸ģ題", "theme_selection": "ä¸ģ題選項", "theme_selection_description": "äžį€čĻŊ器įŗģįĩąååĨŊč‡Ēå‹•č¨­åŽšæˇąã€æˇē色ä¸ģ題", @@ -2247,21 +2254,21 @@ "trash_count": "丟掉 {count, number} 個æĒ”æĄˆ", "trash_delete_asset": "將æĒ”æĄˆä¸Ÿé€˛åžƒåœžæĄļ / åˆĒ除", "trash_emptied": "åˇ˛æ¸…įŠē回æ”ļæĄļ", - "trash_no_results_message": "垃圞æĄļä¸­įš„į…§į‰‡å’ŒåŊąį‰‡å°‡éĄ¯į¤ēåœ¨é€™čŖĄã€‚", + "trash_no_results_message": "垃圞æĄļä¸­įš„į›¸į‰‡å’ŒåŊąį‰‡å°‡éĄ¯į¤ēåœ¨é€™čŖĄã€‚", "trash_page_delete_all": "åˆĒ除全部", "trash_page_empty_trash_dialog_content": "是åĻ清įŠē回æ”ļæĄļīŧŸé€™äē›é …į›Žå°‡čĸĢåžž Immich 中永䚅åˆĒ除", "trash_page_info": "回æ”ļæĄļä¸­é …į›Žå°‡åœ¨ {days} 夊垌永䚅åˆĒ除", "trash_page_no_assets": "æšĢį„Ąåˇ˛åˆĒé™¤é …į›Ž", - "trash_page_restore_all": "æĸ垊全部", + "trash_page_restore_all": "全部還原", "trash_page_select_assets_btn": "é¸æ“‡é …į›Ž", "trash_page_title": "垃圞æĄļ ({count})", "trashed_items_will_be_permanently_deleted_after": "垃圞æĄļä¸­įš„é …į›Žæœƒåœ¨ {days, plural, other {# 夊}}垌永䚅åˆĒ除。", "trigger": "觸į™ŧ", - "trigger_asset_uploaded": "čŗ‡į”ĸåˇ˛ä¸Šå‚ŗ", - "trigger_asset_uploaded_description": "ä¸Šå‚ŗæ–°čŗ‡į”ĸæ™‚č§¸į™ŧ", - "trigger_description": "啟動åˇĨäŊœæĩįš„äē‹äģļ", - "trigger_person_recognized": "čĸĢčĒå¯įš„äēē", - "trigger_person_recognized_description": "į•ļæĒĸæ¸Ŧ到有äēēæ™‚觸į™ŧ", + "trigger_asset_uploaded": "é …į›Žåˇ˛ä¸Šå‚ŗ", + "trigger_asset_uploaded_description": "åœ¨ä¸Šå‚ŗæ–°é …į›Žæ™‚č§¸į™ŧ", + "trigger_description": "觸į™ŧåˇĨäŊœæĩį¨‹įš„äē‹äģļ", + "trigger_person_recognized": "åˇ˛čž¨č­˜äēēį‰Š", + "trigger_person_recognized_description": "åĩæ¸Ŧ到äēēį‰Šæ™‚č§¸į™ŧ", "trigger_type": "觸į™ŧéĄžåž‹", "troubleshoot": "ᖑ雪觪᭔", "type": "éĄžåž‹", @@ -2294,20 +2301,20 @@ "unstack": "取æļˆå †į–Š", "unstack_action_prompt": "{count} 個取æļˆå †į–Š", "unstacked_assets_count": "åˇ˛č§Ŗé™¤å †į–Š {count, plural, other {# 個æĒ”æĄˆ}}", - "unsupported_field_type": "ä¸æ”¯æŒįš„æŦ„äŊéĄžåž‹", + "unsupported_field_type": "ä¸æ”¯æ´įš„æŦ„äŊéĄžåž‹", "untagged": "į„Ąæ¨™įą¤", - "untitled_workflow": "į„Ąæ¨™éĄŒåˇĨäŊœæĩ", + "untitled_workflow": "æœĒå‘Ŋ名åˇĨäŊœæĩį¨‹", "up_next": "下一個", - "update_location_action_prompt": "äŊŋᔍäģĨ下å‘Ŋä줿›´æ–°{count}å€‹æ‰€é¸čŗ‡į”ĸįš„äŊįŊŽīŧš", + "update_location_action_prompt": "更新 {count} å€‹æ‰€é¸é …į›Žįš„äŊįŊŽīŧš", "updated_at": "更新æ–ŧ", "updated_password": "åˇ˛æ›´æ–°å¯†įĸŧ", "upload": "ä¸Šå‚ŗ", - "upload_concurrency": "ä¸Šå‚ŗä¸Ļ行", + "upload_concurrency": "ä¸Šå‚ŗä¸ĻčĄŒæ•¸", "upload_details": "ä¸Šå‚ŗčŠŗį´°čŗ‡č¨Š", "upload_dialog_info": "是åĻčĻå°‡æ‰€é¸é …į›Žå‚™äģŊ到äŧ翜å™¨īŧŸ", "upload_dialog_title": "ä¸Šå‚ŗé …į›Ž", "upload_error_with_count": "{count, plural, one {# å€‹é …į›Ž} other {# å€‹é …į›Ž}}ä¸Šå‚ŗéŒ¯čǤ", - "upload_errors": "ä¸Šå‚ŗåŽŒæˆīŧŒäŊ†æœ‰ {count, plural, other {# č™•æ™‚į™ŧį”ŸéŒ¯čǤ}}īŧŒčρæĒĸčĻ–æ–°ä¸Šå‚ŗįš„æĒ”æĄˆčĢ‹é‡æ–°æ•´į†é éĸ。", + "upload_errors": "ä¸Šå‚ŗåŽŒæˆīŧŒäŊ†æœ‰ {count, plural, other {# 個錯čǤ}}。čĢ‹é‡æ–°æ•´į†é éĸäģĨæŸĨįœ‹æ–°ä¸Šå‚ŗįš„é …į›Žã€‚", "upload_finished": "ä¸Šå‚ŗåŽŒæˆ", "upload_progress": "削餘 {remaining, number} - 厞處ᐆ {processed, number}/{total, number}", "upload_skipped_duplicates": "厞į•Ĩ過 {count, plural, other {# å€‹é‡č¤‡įš„æĒ”æĄˆ}}", @@ -2317,7 +2324,7 @@ "upload_success": "ä¸Šå‚ŗæˆåŠŸīŧŒčρæĒĸčĻ–æ–°ä¸Šå‚ŗįš„æĒ”æĄˆčĢ‹é‡æ–°æ•´į†é éĸ。", "upload_to_immich": "ä¸Šå‚ŗč‡ŗ Immich ({count})", "uploading": "ä¸Šå‚ŗä¸­", - "uploading_media": "åĒ’éĢ”ä¸Šå‚ŗä¸­", + "uploading_media": "é …į›Žä¸Šå‚ŗä¸­", "url": "įļ˛å€", "usage": "į”¨é‡", "use_biometric": "äŊŋį”¨į”Ÿį‰Ščž¨č­˜", @@ -2326,11 +2333,11 @@ "user": "äŊŋᔍ者", "user_has_been_deleted": "æ­¤äŊŋᔍ者厞čĸĢåˆĒ除。", "user_id": "äŊŋᔍ者 ID", - "user_liked": "{user} å–œæ­Ąäē† {type, select, photo {這åŧĩᅧቇ} video {這æŽĩåŊąį‰‡} asset {這個æĒ”æĄˆ} other {厃}}", + "user_liked": "{user} å–œæ­Ąäē† {type, select, photo {這åŧĩᛏቇ} video {這æŽĩåŊąį‰‡} asset {這個æĒ”æĄˆ} other {厃}}", "user_pin_code_settings": "PIN įĸŧ", "user_pin_code_settings_description": "įŽĄį†æ‚¨įš„ PIN įĸŧ", "user_privacy": "äŊŋį”¨č€…éšąį§", - "user_purchase_settings": "čŗŧįŊŽ", + "user_purchase_settings": "čŗŧ財", "user_purchase_settings_description": "įŽĄį†æ‚¨įš„čŗŧ財", "user_role_set": "設 {user} į‚ē{role}", "user_usage_detail": "äŊŋį”¨č€…į”¨é‡čŠŗį´°čŗ‡č¨Š", @@ -2346,12 +2353,12 @@ "variables": "čŽŠæ•¸", "version": "į‰ˆæœŦ", "version_announcement_closing": "æ•ŦįĨé †åŋƒīŧŒAlex", - "version_announcement_message": "嗨īŊžæ–°į‰ˆæœŦįš„ Immich 推å‡ēäē†ã€‚į‚ē防æ­ĸč¨­åŽšį™ŧį”ŸéŒ¯čǤīŧŒčĢ‹čŠąéģžæ™‚間閹莀į™ŧ行čĒĒæ˜ŽīŧŒä¸Ļįĸēäŋč¨­åŽšæ˜¯æœ€æ–°įš„īŧŒį‰šåˆĨ是äŊŋᔍ WatchTower į­‰č‡Ē動更新åˇĨå…ˇæ™‚ã€‚", + "version_announcement_message": "嗨īŧæ–°į‰ˆæœŦįš„ Immich 厞į™ŧ布。čĢ‹čŠąéģžæ™‚間閹莀 į™ŧ行čĒĒæ˜Ž ä¸Ļįĸēäŋæ‚¨įš„č¨­åŽšæ˜¯æœ€æ–°įš„īŧŒäģĨ防æ­ĸäģģäŊ•設åޚ錝čǤīŧŒį‰šåˆĨ是åĻ‚æžœæ‚¨äŊŋᔍ WatchTower 或äģģäŊ•č‡Ē動處ᐆ Immich åŸˇčĄŒå€‹éĢ”æ›´æ–°įš„æŠŸåˆļ。", "version_history": "į‰ˆæœŦį´€éŒ„", "version_history_item": "{date} åŽ‰čŖäē† {version}", "video": "åŊąį‰‡", "video_hover_setting": "éŠæ¨™åœį•™æ™‚æ’­æ”žåŊąį‰‡į¸Žåœ–", - "video_hover_setting_description": "į•ļæģ‘éŧ åœåœ¨é …į›Žä¸Šæ™‚æ’­æ”žåŊąį‰‡į¸Žåœ–ã€‚åŗäŊŋåœį”¨īŧŒå°‡æģ‘éŧ åœåœ¨æ’­æ”žåœ–į¤ē上䚟可äģĨ播攞。", + "video_hover_setting_description": "į•ļæģ‘éŧ åœåœ¨é …į›Žä¸Šæ™‚æ’­æ”žåŊąį‰‡į¸Žåœ–ã€‚åŗäŊŋåœį”¨æ­¤åŠŸčƒŊīŧŒäģå¯é€éŽå°‡æģ‘éŧ åœåœ¨æ’­æ”žåœ–į¤ē上䞆開始播攞。", "videos": "åŊąį‰‡", "videos_count": "{count, plural, other {# 部åŊąį‰‡}}", "videos_only": "åĒå…č¨ąåŊąį‰‡", @@ -2359,7 +2366,7 @@ "view_album": "æĒĸčϖᛏį°ŋ", "view_all": "į€čĻŊ全部", "view_all_users": "æĒĸčĻ–æ‰€æœ‰äŊŋᔍ者", - "view_asset_owners": "æŸĨįœ‹čŗ‡į”ĸæ‰€æœ‰č€…", + "view_asset_owners": "æŸĨįœ‹é …į›Žæ“æœ‰č€…", "view_details": "æĒĸčĻ–čŠŗį´°čŗ‡č¨Š", "view_in_timeline": "在時間čģ¸ä¸­æĒĸčĻ–", "view_link": "æĒĸčϖ逪įĩ", @@ -2368,7 +2375,7 @@ "view_next_asset": "æĒĸčϖ䏋䏀項", "view_previous_asset": "æĒĸčĻ–ä¸Šä¸€é …", "view_qr_code": "æĒĸčĻ– QR code", - "view_similar_photos": "æĒĸčϖᛏäŧŧᅧቇ", + "view_similar_photos": "æĒĸčϖᛏäŧŧᛏቇ", "view_stack": "æĒĸčĻ–å †į–Š", "view_user": "éĄ¯į¤ēäŊŋᔍ者", "viewer_remove_from_stack": "åžžå †į–Šä¸­į§ģ除", @@ -2385,26 +2392,26 @@ "welcome_to_immich": "æ­ĄčŋŽäŊŋᔍ Immich", "width": "å¯ŦåēĻ", "wifi_name": "Wi-Fi åį¨ą", - "workflow_delete_prompt": "您įĸē厚čĻåˆ é™¤æ­¤åˇĨäŊœæĩå—ŽīŧŸ", - "workflow_deleted": "åˇĨäŊœæĩåˇ˛åˆ é™¤", - "workflow_description": "åˇĨäŊœæĩæčŋ°", - "workflow_info": "åˇĨäŊœæĩčŗ‡č¨Š", - "workflow_json": "åˇĨäŊœæĩį¨‹JSON", - "workflow_json_help": "äģĨJSONæ ŧåŧįˇ¨čŧ¯åˇĨäŊœæĩé…å¯˜ã€‚ 更攚將同æ­Ĩ到čĻ–čĻē化構åģē器。", - "workflow_name": "åˇĨäŊœæĩåį¨ą", - "workflow_navigation_prompt": "您įĸē厚不äŋå­˜æ›´æ”šå°ąé›ĸ開嗎īŧŸ", - "workflow_summary": "åˇĨäŊœæĩæ‘˜čρ", - "workflow_update_success": "åˇĨäŊœæĩåˇ˛æˆåŠŸæ›´æ–°", - "workflow_updated": "åˇĨäŊœæĩåˇ˛æ›´æ–°", - "workflows": "åˇĨäŊœæĩ", - "workflows_help_text": "åˇĨäŊœæĩæ šæ“šč§¸į™ŧå™¨å’Œį¯ŠæĒĸፋåŧč‡Ēå‹•åŸˇčĄŒčŗ‡į”ĸ操äŊœ", + "workflow_delete_prompt": "įĸē厚čρåˆĒ除此åˇĨäŊœæĩį¨‹å—ŽīŧŸ", + "workflow_deleted": "åˇĨäŊœæĩį¨‹åˇ˛åˆĒ除", + "workflow_description": "åˇĨäŊœæĩį¨‹čĒĒæ˜Ž", + "workflow_info": "åˇĨäŊœæĩį¨‹čŠŗį´°čŗ‡č¨Š", + "workflow_json": "åˇĨäŊœæĩį¨‹ JSON", + "workflow_json_help": "äģĨ JSON æ ŧåŧįˇ¨čŧ¯åˇĨäŊœæĩį¨‹č¨­åŽšã€‚čŽŠæ›´å°‡åŒæ­Ĩ臺čĻ–čĻēåŒ–įˇ¨čŧ¯å™¨ã€‚", + "workflow_name": "åˇĨäŊœæĩį¨‹åį¨ą", + "workflow_navigation_prompt": "您įĸē厚čĻä¸å„˛å­˜čŽŠæ›´å°ąé›ĸ開嗎īŧŸ", + "workflow_summary": "åˇĨäŊœæĩį¨‹æ‘˜čρ", + "workflow_update_success": "åˇ˛æˆåŠŸæ›´æ–°åˇĨäŊœæĩį¨‹", + "workflow_updated": "åˇĨäŊœæĩį¨‹åˇ˛æ›´æ–°", + "workflows": "åˇĨäŊœæĩį¨‹", + "workflows_help_text": "æ šæ“šč§¸į™ŧæĸäģļčˆ‡į¯Šé¸å™¨č‡Ēå‹•åŸˇčĄŒå‹•äŊœ", "wrong_pin_code": "PIN įĸŧ錯čǤ", "year": "åš´", "years_ago": "{years, plural, other {# åš´}}前", "yes": "是", - "you_dont_have_any_shared_links": "æ‚¨æ˛’æœ‰äģģäŊ•å…ąäēĢ逪įĩ", + "you_dont_have_any_shared_links": "æ‚¨æ˛’æœ‰äģģäŊ•分äēĢ逪įĩ", "your_wifi_name": "æ‚¨įš„ Wi-Fi åį¨ą", - "zero_to_clear_rating": "按0æ¸…é™¤čŗ‡į”ĸčŠ•æ˜Ÿ", + "zero_to_clear_rating": "按 0 äģĨæ¸…é™¤é …į›ŽčŠ•åˆ†", "zoom_image": "į¸Žæ”žåœ–į‰‡", "zoom_to_bounds": "į¸Žæ”žåˆ°é‚Šį•Œ" } diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 9db6fd78dd..efbe710dbc 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,8 +1,8 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:667cf70698924920f29ebdb8d749ab665811503b87093d4f11826d114fd7255e AS builder-cpu +FROM python:3.11-bookworm@sha256:aa23850b91cb4c7faedac8ca9aa74ddc6eb03529a519145a589a7f35df4c5927 AS builder-cpu -FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS builder-openvino +FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c34ba9d7ecc23d0755e35f AS builder-openvino FROM builder-cpu AS builder-cuda @@ -83,12 +83,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \ uv pip install /opt/onnxruntime_rocm-*.whl; \ fi -FROM python:3.11-slim-bookworm@sha256:917ec0e42cd6af87657a768449c2f604a6b67c7ab8e10ff917b8724799f816d3 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:04cd27899595a99dfe77709d96f08876bf2ee99139ee2f0fe9ac948005034e5b AS prod-cpu ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \ MACHINE_LEARNING_MODEL_ARENA=false -FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS prod-openvino +FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c34ba9d7ecc23d0755e35f AS prod-openvino RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index dda5141363..c43d0df2cc 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -8,14 +8,13 @@ readme = "README.md" dependencies = [ "aiocache>=0.12.1,<1.0", "fastapi>=0.95.2,<1.0", - "ftfy>=6.1.1", "gunicorn>=21.1.0", "huggingface-hub>=0.20.1,<1.0", "insightface>=0.7.3,<1.0", "numpy>=2.3.4", "opencv-python-headless>=4.7.0.72,<5.0", "orjson>=3.9.5", - "pillow>=9.5.0,<11.0", + "pillow>=12.1.1,<12.2", "pydantic>=2.0.0,<3", "pydantic-settings>=2.5.2,<3", "python-multipart>=0.0.6,<1.0", diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 4ec64e05fa..25f59a8fe5 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -379,50 +379,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.4" +version = "7.13.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716, upload-time = "2024-10-20T22:57:39.682Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/31/9c0cf84f0dfcbe4215b7eb95c31777cdc0483c13390e69584c8150c85175/coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", size = 206819, upload-time = "2024-10-20T22:56:20.132Z" }, - { url = "https://files.pythonhosted.org/packages/53/ed/a38401079ad320ad6e054a01ec2b61d270511aeb3c201c80e99c841229d5/coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", size = 207263, upload-time = "2024-10-20T22:56:21.88Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/c3ad33b179ab4213f0d70da25a9c214d52464efa11caeab438592eb1d837/coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", size = 239205, upload-time = "2024-10-20T22:56:23.03Z" }, - { url = "https://files.pythonhosted.org/packages/36/91/fc02e8d8e694f557752120487fd982f654ba1421bbaa5560debf96ddceda/coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", size = 236612, upload-time = "2024-10-20T22:56:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/cc/57/cb08f0eda0389a9a8aaa4fc1f9fec7ac361c3e2d68efd5890d7042c18aa3/coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", size = 238479, upload-time = "2024-10-20T22:56:26.749Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c9/2c7681a9b3ca6e6f43d489c2e6653a53278ed857fd6e7010490c307b0a47/coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", size = 237405, upload-time = "2024-10-20T22:56:27.958Z" }, - { url = "https://files.pythonhosted.org/packages/b5/4e/ebfc6944b96317df8b537ae875d2e57c27b84eb98820bc0a1055f358f056/coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", size = 236038, upload-time = "2024-10-20T22:56:29.816Z" }, - { url = "https://files.pythonhosted.org/packages/13/f2/3a0bf1841a97c0654905e2ef531170f02c89fad2555879db8fe41a097871/coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", size = 236812, upload-time = "2024-10-20T22:56:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9c/66bf59226b52ce6ed9541b02d33e80a6e816a832558fbdc1111a7bd3abd4/coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", size = 209400, upload-time = "2024-10-20T22:56:33.569Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a0/b0790934c04dfc8d658d4a62acb8f7ca0efdf3818456fcad757b11c6479d/coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", size = 210243, upload-time = "2024-10-20T22:56:34.863Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e7/9291de916d084f41adddfd4b82246e68d61d6a75747f075f7e64628998d2/coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", size = 207013, upload-time = "2024-10-20T22:56:36.034Z" }, - { url = "https://files.pythonhosted.org/packages/27/03/932c2c5717a7fa80cd43c6a07d3177076d97b79f12f40f882f9916db0063/coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", size = 207251, upload-time = "2024-10-20T22:56:38.054Z" }, - { url = "https://files.pythonhosted.org/packages/d5/3f/0af47dcb9327f65a45455fbca846fe96eb57c153af46c4754a3ba678938a/coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", size = 240268, upload-time = "2024-10-20T22:56:40.051Z" }, - { url = "https://files.pythonhosted.org/packages/8a/3c/37a9d81bbd4b23bc7d46ca820e16174c613579c66342faa390a271d2e18b/coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", size = 237298, upload-time = "2024-10-20T22:56:41.929Z" }, - { url = "https://files.pythonhosted.org/packages/c0/70/6b0627e5bd68204ee580126ed3513140b2298995c1233bd67404b4e44d0e/coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", size = 239367, upload-time = "2024-10-20T22:56:43.141Z" }, - { url = "https://files.pythonhosted.org/packages/3c/eb/634d7dfab24ac3b790bebaf9da0f4a5352cbc125ce6a9d5c6cf4c6cae3c7/coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", size = 238853, upload-time = "2024-10-20T22:56:44.33Z" }, - { url = "https://files.pythonhosted.org/packages/d9/0d/8e3ed00f1266ef7472a4e33458f42e39492e01a64281084fb3043553d3f1/coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", size = 237160, upload-time = "2024-10-20T22:56:46.258Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9c/4337f468ef0ab7a2e0887a9c9da0e58e2eada6fc6cbee637a4acd5dfd8a9/coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", size = 238824, upload-time = "2024-10-20T22:56:48.666Z" }, - { url = "https://files.pythonhosted.org/packages/5e/09/3e94912b8dd37251377bb02727a33a67ee96b84bbbe092f132b401ca5dd9/coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", size = 209639, upload-time = "2024-10-20T22:56:50.664Z" }, - { url = "https://files.pythonhosted.org/packages/01/69/d4f3a4101171f32bc5b3caec8ff94c2c60f700107a6aaef7244b2c166793/coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", size = 210428, upload-time = "2024-10-20T22:56:52.468Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039, upload-time = "2024-10-20T22:56:53.656Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298, upload-time = "2024-10-20T22:56:54.979Z" }, - { url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813, upload-time = "2024-10-20T22:56:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959, upload-time = "2024-10-20T22:56:58.06Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950, upload-time = "2024-10-20T22:56:59.329Z" }, - { url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610, upload-time = "2024-10-20T22:57:00.645Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697, upload-time = "2024-10-20T22:57:01.944Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541, upload-time = "2024-10-20T22:57:03.848Z" }, - { url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707, upload-time = "2024-10-20T22:57:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439, upload-time = "2024-10-20T22:57:06.35Z" }, - { url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784, upload-time = "2024-10-20T22:57:07.857Z" }, - { url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058, upload-time = "2024-10-20T22:57:09.845Z" }, - { url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772, upload-time = "2024-10-20T22:57:11.147Z" }, - { url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490, upload-time = "2024-10-20T22:57:13.02Z" }, - { url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848, upload-time = "2024-10-20T22:57:14.927Z" }, - { url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340, upload-time = "2024-10-20T22:57:16.246Z" }, - { url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229, upload-time = "2024-10-20T22:57:17.546Z" }, - { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510, upload-time = "2024-10-20T22:57:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353, upload-time = "2024-10-20T22:57:20.891Z" }, - { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502, upload-time = "2024-10-20T22:57:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [package.optional-dependencies] @@ -472,17 +523,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/0a/d2/deb3296d08097fedd [[package]] name = "fastapi" -version = "0.127.1" +version = "0.128.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/8a/6b9ba6eb8ff3817caae83120495965d9e70afb4d6348cb120e464ee199f4/fastapi-0.127.1.tar.gz", hash = "sha256:946a87ee5d931883b562b6bada787d6c8178becee2683cb3f9b980d593206359", size = 391876, upload-time = "2025-12-26T13:04:47.075Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/f3/a6858d147ed2645c095d11dc2440f94a5f1cd8f4df888e3377e6b5281a0f/fastapi-0.127.1-py3-none-any.whl", hash = "sha256:31d670a4f9373cc6d7994420f98e4dc46ea693145207abc39696746c83a44430", size = 112332, upload-time = "2025-12-26T13:04:45.329Z" }, + { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, ] [[package]] @@ -829,7 +881,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.34.4" +version = "0.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -841,9 +893,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/c9/bdbe19339f76d12985bc03572f330a01a93c04dffecaaea3061bdd7fb892/huggingface_hub-0.34.4.tar.gz", hash = "sha256:a4228daa6fb001be3f4f4bdaf9a0db00e1739235702848df00885c9b5742c85c", size = 459768, upload-time = "2025-08-08T09:14:52.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/7b/bb06b061991107cd8783f300adff3e7b7f284e330fd82f507f2a1417b11d/huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a", size = 561452, upload-time = "2025-08-08T09:14:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, ] [[package]] @@ -978,7 +1030,7 @@ requires-dist = [ { name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" }, { name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" }, { name = "orjson", specifier = ">=3.9.5" }, - { name = "pillow", specifier = ">=9.5.0,<11.0" }, + { name = "pillow", specifier = ">=12.1.1,<12.2" }, { name = "pydantic", specifier = ">=2.0.0,<3" }, { name = "pydantic-settings", specifier = ">=2.5.2,<3" }, { name = "python-multipart", specifier = ">=0.0.6,<1.0" }, @@ -1205,7 +1257,7 @@ wheels = [ [[package]] name = "locust" -version = "2.42.6" +version = "2.43.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "configargparse" }, @@ -1214,7 +1266,6 @@ dependencies = [ { name = "flask-login" }, { name = "gevent" }, { name = "geventhttpclient" }, - { name = "locust-cloud" }, { name = "msgpack" }, { name = "psutil" }, { name = "pytest" }, @@ -1226,25 +1277,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/19/dd816835679c80eba9c339a4bfcb6380fa8b059a5da45894ac80d73bc504/locust-2.42.6.tar.gz", hash = "sha256:fa603f4ac1c48b9ac56f4c34355944ebfd92590f4197b6d126ea216bd81cc036", size = 1418806, upload-time = "2025-11-29T17:40:10.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/c5/7d7bd50ac744bc209a4bcbeb74660d7ae450a44441737efe92ee9d8ea6a7/locust-2.43.3.tar.gz", hash = "sha256:b5d2c48f8f7d443e3abdfdd6ec2f7aebff5cd74fab986bcf1e95b375b5c5a54b", size = 1445349, upload-time = "2026-02-12T09:55:34.591Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/4f/be2b7b87a4cea00d89adabeee5c61e8831c2af8a0eca3cbe931516f0e155/locust-2.42.6-py3-none-any.whl", hash = "sha256:2d02502489c8a2e959e2ca4b369c81bbd6b9b9e831d9422ab454541a3c2c6252", size = 1437376, upload-time = "2025-11-29T17:40:08.37Z" }, -] - -[[package]] -name = "locust-cloud" -version = "1.30.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "configargparse" }, - { name = "gevent" }, - { name = "platformdirs" }, - { name = "python-engineio" }, - { name = "python-socketio", extra = ["client"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8d/86/cd6b611f008387ffce5bcb6132ba7431aec7d1b09d8ce27e152e96d94315/locust_cloud-1.30.0.tar.gz", hash = "sha256:324ae23754d49816df96d3f7472357a61cd10e56cebcb26e2def836675cb3c68", size = 457297, upload-time = "2025-12-15T13:35:50.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/db/35c1cc8e01dfa570913255c55eb983a7e2e532060b4d1ee5f1fb543a6a0b/locust_cloud-1.30.0-py3-none-any.whl", hash = "sha256:2324b690efa1bfc8d1871340276953cf265328bd6333e07a5ba8ff7dc5e99e6c", size = 413446, upload-time = "2025-12-15T13:35:48.75Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d2/dc5379876d3a481720803653ea4d219f0c26f2d2b37c9243baaa16d0bc79/locust-2.43.3-py3-none-any.whl", hash = "sha256:e032c119b54a9d984cb74a936ee83cfd7d68b3c76c8f308af63d04f11396b553", size = 1463473, upload-time = "2026-02-12T09:55:31.727Z" }, ] [[package]] @@ -1495,83 +1530,81 @@ wheels = [ [[package]] name = "numpy" -version = "2.3.4" +version = "2.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, - { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload-time = "2025-10-15T16:15:29.044Z" }, - { url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload-time = "2025-10-15T16:15:31.106Z" }, - { url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload-time = "2025-10-15T16:15:33.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload-time = "2025-10-15T16:15:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload-time = "2025-10-15T16:15:38.225Z" }, - { url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload-time = "2025-10-15T16:15:40.404Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload-time = "2025-10-15T16:15:42.896Z" }, - { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" }, - { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" }, - { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" }, - { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" }, - { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" }, - { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" }, - { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" }, - { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" }, - { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" }, - { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" }, - { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" }, - { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" }, - { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" }, - { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" }, - { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" }, - { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" }, - { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" }, - { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" }, - { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" }, - { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" }, - { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" }, - { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" }, - { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" }, - { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" }, - { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" }, - { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" }, - { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" }, - { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" }, - { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" }, - { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" }, - { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" }, - { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, - { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload-time = "2025-10-15T16:18:04.271Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload-time = "2025-10-15T16:18:06.668Z" }, - { url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload-time = "2025-10-15T16:18:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, ] [[package]] @@ -1626,10 +1659,9 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.23.2" +version = "1.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs" }, { name = "flatbuffers" }, { name = "numpy" }, { name = "packaging" }, @@ -1637,31 +1669,33 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, - { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, - { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, - { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, - { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, + { url = "https://files.pythonhosted.org/packages/d2/88/d9757c62a0f96b5193f8d447a141eefd14498c404cc5caf1a6f3233cf102/onnxruntime-1.24.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:79b3119ab9f4f3817062e6dbe7f4a44937de93905e3a31ba34313d18cb49e7be", size = 17212018, upload-time = "2026-02-05T17:32:13.986Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/b3305c39144e19dbe8791802076b29b4b592b09de03d0e340c1314bfd408/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86bc43e922b1f581b3de26a3dc402149c70e5542fceb5bec6b3a85542dbeb164", size = 15018703, upload-time = "2026-02-05T17:30:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/d273b75fe7825ea3feed321dd540aef33d8a1380ddd8ac3bb70a8ed000fe/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cabe71ca14dcfbf812d312aab0a704507ac909c137ee6e89e4908755d0fc60e", size = 17096352, upload-time = "2026-02-05T17:31:29.057Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/0616101a3938bfe2918ea60b581a9bbba61ffc255c63388abb0885f7ce18/onnxruntime-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:3273c330f5802b64b4103e87b5bbc334c0355fff1b8935d8910b0004ce2f20c8", size = 12493235, upload-time = "2026-02-05T17:32:04.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/30/437de870e4e1c6d237a2ca5e11f54153531270cb5c745c475d6e3d5c5dcf/onnxruntime-1.24.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7307aab9e2e879c0171f37e0eb2808a5b4aec7ba899bb17c5f0cedfc301a8ac2", size = 17211043, upload-time = "2026-02-05T17:32:16.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/60/004401cd86525101ad8aa9eec301327426555d7a77fac89fd991c3c7aae6/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780add442ce2d4175fafb6f3102cdc94243acffa3ab16eacc03dd627cc7b1b54", size = 15016224, upload-time = "2026-02-05T17:30:56.791Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a1/43ad01b806a1821d1d6f98725edffcdbad54856775643718e9124a09bfbe/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6119526eda12613f0d0498e2ae59563c247c370c9cef74c2fc93133dde157", size = 17098191, upload-time = "2026-02-05T17:31:31.87Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/5beb65270864037d5c8fb25cfe6b23c48b618d1f4d06022d425cbf29bd9c/onnxruntime-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0af2f1cfcfff9094971c7eb1d1dfae7ccf81af197493c4dc4643e4342c0946", size = 12493108, upload-time = "2026-02-05T17:32:07.076Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/7172ecfcbdabd92f338e694f38c325f6fab29a38fa0a8c3d1c85b9f4617c/onnxruntime-1.24.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:82e367770e8fba8a87ba9f4c04bb527e6d4d7204540f1390f202c27a3b759fb4", size = 17211381, upload-time = "2026-02-05T17:31:09.601Z" }, + { url = "https://files.pythonhosted.org/packages/79/5b/532a0d75b93bbd0da0e108b986097ebe164b84fbecfdf2ddbf7c8a3a2e83/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1099f3629832580fedf415cfce2462a56cc9ca2b560d6300c24558e2ac049134", size = 15016000, upload-time = "2026-02-05T17:31:00.116Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b5/40606c7bce0702975a077bc6668cd072cd77695fc5c0b3fcf59bdb1fe65e/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6361dda4270f3939a625670bd67ae0982a49b7f923207450e28433abc9c3a83b", size = 17097637, upload-time = "2026-02-05T17:31:34.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/9e8f7933796b466241b934585723c700d8fb6bde2de856e65335193d7c93/onnxruntime-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:bd1e4aefe73b6b99aa303cd72562ab6de3cccb09088100f8ad1c974be13079c7", size = 12492467, upload-time = "2026-02-05T17:32:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8a/ee07d86e35035f9fed42497af76435f5a613d4e8b6c537ea0f8ef9fa85da/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88a2b54dca00c90fca6303eedf13d49b5b4191d031372c2e85f5cffe4d86b79e", size = 15025407, upload-time = "2026-02-05T17:31:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9e/ab3e1dda4b126313d240e1aaa87792ddb1f5ba6d03ca2f093a7c4af8c323/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dfbba602da840615ed5b431facda4b3a43b5d8276cf9e0dbf13d842df105838", size = 17099810, upload-time = "2026-02-05T17:31:37.537Z" }, + { url = "https://files.pythonhosted.org/packages/87/23/167d964414cee2af9c72af323b28d2c4cb35beed855c830a23f198265c79/onnxruntime-1.24.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:890c503ca187bc883c3aa72c53f2a604ec8e8444bdd1bf6ac243ec6d5e085202", size = 17214004, upload-time = "2026-02-05T17:31:11.917Z" }, + { url = "https://files.pythonhosted.org/packages/b4/24/6e5558fdd51027d6830cf411bc003ae12c64054826382e2fab89e99486a0/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da1b84b3bdeec543120df169e5e62a1445bf732fc2c7fb036c2f8a4090455e8", size = 15017034, upload-time = "2026-02-05T17:31:04.331Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/3cb1c9eaae1103265ed7eb00a3eaeb0d9ba51dc88edc398b7071c9553bed/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:557753ec345efa227c6a65139f3d29c76330fcbd54cc10dd1b64232ebb939c13", size = 17097531, upload-time = "2026-02-05T17:31:40.303Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/4522b199c12db7c5b46aaf265ee0d741abe65ea912f6c0aaa2cc18a4654d/onnxruntime-1.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:ea4942104805e868f3ddddfa1fbb58b04503a534d489ab2d1452bbfa345c78c2", size = 12795556, upload-time = "2026-02-05T17:32:11.886Z" }, + { url = "https://files.pythonhosted.org/packages/a1/53/3b8969417276b061ff04502ccdca9db4652d397abbeb06c9f6ae05cec9ca/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea8963a99e0f10489acdf00ef3383c3232b7e44aa497b063c63be140530d9f85", size = 15025434, upload-time = "2026-02-05T17:31:06.942Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/cfcf009eb38d90cc628c087b6506b3dfe1263387f3cbbf8d272af4fef957/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34488aa760fb5c2e6d06a7ca9241124eb914a6a06f70936a14c669d1b3df9598", size = 17099815, upload-time = "2026-02-05T17:31:43.092Z" }, ] [[package]] name = "onnxruntime-gpu" -version = "1.23.2" +version = "1.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs" }, { name = "flatbuffers" }, { name = "numpy" }, { name = "packaging" }, @@ -1669,13 +1703,16 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/43/a4/e3d7fbe32b44e814ae24ed642f05fac5d96d120efd82db7a7cac936e85a9/onnxruntime_gpu-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d76d1ac7a479ecc3ac54482eea4ba3b10d68e888a0f8b5f420f0bdf82c5eec59", size = 300525715, upload-time = "2025-10-22T16:56:19.928Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5c/dba7c009e73dcce02e7f714574345b5e607c5c75510eb8d7bef682b45e5d/onnxruntime_gpu-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:054282614c2fc9a4a27d74242afbae706a410f1f63cc35bc72f99709029a5ba4", size = 244506823, upload-time = "2025-10-22T16:55:09.526Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d9/b7140a4f1615195938c7e358c0804bb84271f0d6886b5cbf105c6cb58aae/onnxruntime_gpu-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2d1f720685d729b5258ec1b36dee1de381b8898189908c98cbeecdb2f2b5c2", size = 300509596, upload-time = "2025-10-22T16:56:31.728Z" }, - { url = "https://files.pythonhosted.org/packages/87/da/2685c79e5ea587beddebe083601fead0bdf3620bc2f92d18756e7de8a636/onnxruntime_gpu-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:fe925a84b00e291e0ad3fac29bfd8f8e06112abc760cdc82cb711b4f3935bd95", size = 244508327, upload-time = "2025-10-22T16:55:19.397Z" }, - { url = "https://files.pythonhosted.org/packages/03/05/40d561636e4114b54aa06d2371bfbca2d03e12cfdf5d4b85814802f18a75/onnxruntime_gpu-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e8f75af5da07329d0c3a5006087f4051d8abd133b4be7c9bae8cdab7bea4c26", size = 300515567, upload-time = "2025-10-22T16:56:43.794Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3b/418300438063d403384c79eaef1cb13c97627042f2247b35a887276a355a/onnxruntime_gpu-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:7f1b3f49e5e126b99e23ec86b4203db41c2a911f6165f7624f2bc8267aaca767", size = 244507535, upload-time = "2025-10-22T16:55:28.532Z" }, - { url = "https://files.pythonhosted.org/packages/b8/dc/80b145e3134d7eba31309b3299a2836e37c76e4c419a261ad9796f8f8d65/onnxruntime_gpu-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20959cd4ae358aab6579ab9123284a7b1498f7d51ec291d429a5edc26511306f", size = 300525759, upload-time = "2025-10-22T16:56:56.925Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c7/07d06175f1124fc89e8b7da30d70eb8e0e1400d90961ae1cbea9da69e69b/onnxruntime_gpu-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac4bfc90c376516b13d709764ab257e4e3d78639bf6a2ccfc826e9db4a5c7ddf", size = 252616647, upload-time = "2026-02-05T17:24:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/47c2a873bf5fc307cda696e8a8cb54b7c709f5a4b3f9e2b4a636066a63c2/onnxruntime_gpu-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:ccd800875cb6c04ce623154c7fa312da21631ef89a9543c9a21593817cfa3473", size = 207089749, upload-time = "2026-02-05T17:23:59.5Z" }, + { url = "https://files.pythonhosted.org/packages/db/a8/fb1a36a052321a839cc9973f6cfd630709412a24afff2d7315feb3efc4b8/onnxruntime_gpu-1.24.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:710bf83751e6761584ad071102af3cbffd4b42bb77b2e3caacfb54ffbaa0666b", size = 252628733, upload-time = "2026-02-05T17:24:12.926Z" }, + { url = "https://files.pythonhosted.org/packages/52/65/48f694b81a963f3ee575041d5f2879b15268f5e7e14d90c3e671836c9646/onnxruntime_gpu-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:b128a42b3fa098647765ba60c2af9d4bf839181307cfac27da649364feb37f7b", size = 207089008, upload-time = "2026-02-05T17:24:07.126Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e7/4e19062e95d3701c0d32c228aa848ba4a1cc97651e53628d978dba8e1267/onnxruntime_gpu-1.24.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db9acb0d0e59d93b4fa6b7fd44284ece4408d0acee73235d43ed343f8cee7ee5", size = 252629216, upload-time = "2026-02-05T17:24:24.604Z" }, + { url = "https://files.pythonhosted.org/packages/c4/82/223d7120d8a98b07c104ddecfb0cc2536188e566a4e9c2dee7572453f89c/onnxruntime_gpu-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:59fdb40743f0722f3b859209f649ea160ca6bb42799e43f49b70a3ec5fc8c4ad", size = 207089285, upload-time = "2026-02-05T17:24:18.497Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/3159e57f09d7e6c8ad47d8ba8d5bd7494f383bc1071481cf38c9c8142bf9/onnxruntime_gpu-1.24.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88ca04e1dffea2d4c3c79cf4de7f429e99059d085f21b3e775a8d36380cd5186", size = 252633977, upload-time = "2026-02-05T17:24:33.568Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/51ad0ab878ff1456a831a0566b4db982a904e22f138e4b2c5f021bac517f/onnxruntime_gpu-1.24.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ced66900b1f48bddb62b5233925c3b56f8e008e2c34ebf8c060b20cae5842bcf", size = 252629039, upload-time = "2026-02-05T17:24:43.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/336d4e09a6af66532eedde5c8f03a73eaa91a046b408522259ab6a604363/onnxruntime_gpu-1.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:129f6ae8b331a6507759597cd317b23e94aed6ead1da951f803c3328f2990b0c", size = 209487551, upload-time = "2026-02-05T17:24:26.373Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/a3b20276261f5e64dbd72bda656af988282cff01f18c2685953600e2f810/onnxruntime_gpu-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2cee7e12b0f4813c62f9a48df83fd01d066cc970400c832252cf3c155a6957", size = 252633096, upload-time = "2026-02-05T17:24:53.248Z" }, ] [[package]] @@ -1718,87 +1755,88 @@ wheels = [ [[package]] name = "opencv-python-headless" -version = "4.11.0.86" +version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929, upload-time = "2025-01-16T13:53:40.22Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460, upload-time = "2025-01-16T13:52:57.015Z" }, - { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330, upload-time = "2025-01-16T13:55:45.731Z" }, - { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060, upload-time = "2025-01-16T13:51:59.625Z" }, - { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856, upload-time = "2025-01-16T13:53:29.654Z" }, - { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425, upload-time = "2025-01-16T13:52:49.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386, upload-time = "2025-01-16T13:52:56.418Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, ] [[package]] name = "orjson" -version = "3.11.5" +version = "3.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/68/6b3659daec3a81aed5ab47700adb1a577c76a5452d35b91c88efee89987f/orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8", size = 245318, upload-time = "2025-12-06T15:54:02.355Z" }, - { url = "https://files.pythonhosted.org/packages/e9/00/92db122261425f61803ccf0830699ea5567439d966cbc35856fe711bfe6b/orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc", size = 129491, upload-time = "2025-12-06T15:54:03.877Z" }, - { url = "https://files.pythonhosted.org/packages/94/4f/ffdcb18356518809d944e1e1f77589845c278a1ebbb5a8297dfefcc4b4cb/orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968", size = 132167, upload-time = "2025-12-06T15:54:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/0a8caff96f4503f4f7dd44e40e90f4d14acf80d3b7a97cb88747bb712d3e/orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7", size = 130516, upload-time = "2025-12-06T15:54:06.274Z" }, - { url = "https://files.pythonhosted.org/packages/4d/63/43d4dc9bd9954bff7052f700fdb501067f6fb134a003ddcea2a0bb3854ed/orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd", size = 135695, upload-time = "2025-12-06T15:54:07.702Z" }, - { url = "https://files.pythonhosted.org/packages/87/6f/27e2e76d110919cb7fcb72b26166ee676480a701bcf8fc53ac5d0edce32f/orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9", size = 139664, upload-time = "2025-12-06T15:54:08.828Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/5966153a5f1be49b5fbb8ca619a529fde7bc71aa0a376f2bb83fed248bcd/orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef", size = 137289, upload-time = "2025-12-06T15:54:09.898Z" }, - { url = "https://files.pythonhosted.org/packages/a7/34/8acb12ff0299385c8bbcbb19fbe40030f23f15a6de57a9c587ebf71483fb/orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9", size = 138784, upload-time = "2025-12-06T15:54:11.022Z" }, - { url = "https://files.pythonhosted.org/packages/ee/27/910421ea6e34a527f73d8f4ee7bdffa48357ff79c7b8d6eb6f7b82dd1176/orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125", size = 141322, upload-time = "2025-12-06T15:54:12.427Z" }, - { url = "https://files.pythonhosted.org/packages/87/a3/4b703edd1a05555d4bb1753d6ce44e1a05b7a6d7c164d5b332c795c63d70/orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814", size = 413612, upload-time = "2025-12-06T15:54:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/1b/36/034177f11d7eeea16d3d2c42a1883b0373978e08bc9dad387f5074c786d8/orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5", size = 150993, upload-time = "2025-12-06T15:54:15.189Z" }, - { url = "https://files.pythonhosted.org/packages/44/2f/ea8b24ee046a50a7d141c0227c4496b1180b215e728e3b640684f0ea448d/orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880", size = 141774, upload-time = "2025-12-06T15:54:16.451Z" }, - { url = "https://files.pythonhosted.org/packages/8a/12/cc440554bf8200eb23348a5744a575a342497b65261cd65ef3b28332510a/orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d", size = 135109, upload-time = "2025-12-06T15:54:17.73Z" }, - { url = "https://files.pythonhosted.org/packages/a3/83/e0c5aa06ba73a6760134b169f11fb970caa1525fa4461f94d76e692299d9/orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1", size = 133193, upload-time = "2025-12-06T15:54:19.426Z" }, - { url = "https://files.pythonhosted.org/packages/cb/35/5b77eaebc60d735e832c5b1a20b155667645d123f09d471db0a78280fb49/orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c", size = 126830, upload-time = "2025-12-06T15:54:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, - { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, - { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, - { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, - { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, - { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" }, - { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" }, - { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" }, - { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" }, - { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" }, - { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" }, - { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" }, - { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" }, - { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" }, - { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" }, - { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" }, - { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" }, - { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" }, - { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" }, - { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" }, - { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" }, - { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" }, - { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" }, - { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" }, - { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, ] [[package]] @@ -1821,52 +1859,89 @@ wheels = [ [[package]] name = "pillow" -version = "10.4.0" +version = "12.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265, upload-time = "2024-07-01T09:45:49.812Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655, upload-time = "2024-07-01T09:45:52.462Z" }, - { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304, upload-time = "2024-07-01T09:45:55.006Z" }, - { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804, upload-time = "2024-07-01T09:45:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126, upload-time = "2024-07-01T09:46:00.713Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541, upload-time = "2024-07-01T09:46:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616, upload-time = "2024-07-01T09:46:05.356Z" }, - { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802, upload-time = "2024-07-01T09:46:08.145Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213, upload-time = "2024-07-01T09:46:10.211Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498, upload-time = "2024-07-01T09:46:12.685Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219, upload-time = "2024-07-01T09:46:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980, upload-time = "2024-07-01T09:46:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799, upload-time = "2024-07-01T09:46:21.883Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973, upload-time = "2024-07-01T09:46:24.321Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054, upload-time = "2024-07-01T09:46:26.825Z" }, - { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484, upload-time = "2024-07-01T09:46:29.355Z" }, - { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375, upload-time = "2024-07-01T09:46:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773, upload-time = "2024-07-01T09:46:33.73Z" }, - { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690, upload-time = "2024-07-01T09:46:36.587Z" }, - { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951, upload-time = "2024-07-01T09:46:38.777Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427, upload-time = "2024-07-01T09:46:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685, upload-time = "2024-07-01T09:46:45.194Z" }, - { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883, upload-time = "2024-07-01T09:46:47.331Z" }, - { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837, upload-time = "2024-07-01T09:46:49.647Z" }, - { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562, upload-time = "2024-07-01T09:46:51.811Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761, upload-time = "2024-07-01T09:46:53.961Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767, upload-time = "2024-07-01T09:46:56.664Z" }, - { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989, upload-time = "2024-07-01T09:46:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255, upload-time = "2024-07-01T09:47:01.189Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" }, - { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.3.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291, upload-time = "2025-03-19T20:36:10.989Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload-time = "2025-03-19T20:36:09.038Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] @@ -1956,7 +2031,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.7" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1964,88 +2039,120 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] [[package]] @@ -2106,28 +2213,28 @@ wheels = [ [[package]] name = "pytest-cov" -version = "6.2.1" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] name = "pytest-mock" -version = "3.14.1" +version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] [[package]] @@ -2165,11 +2272,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.21" +version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] @@ -2320,7 +2427,7 @@ wheels = [ [[package]] name = "rapidocr" -version = "3.4.5" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorlog" }, @@ -2336,7 +2443,7 @@ dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/be/5a/9a61f7c3250d7651c2043e763045e1181fe2fd12d0d5879f726f351818ad/rapidocr-3.4.5-py3-none-any.whl", hash = "sha256:6fb21ffb55b3aa49fee2a7c5cc5190851180e5be538b076b2166b7f44213cd5c", size = 15060573, upload-time = "2025-12-18T03:16:15.738Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/0d025466f0f84552634f2a94c018df34568fe55cc97184a6bb2c719c5b3a/rapidocr-3.6.0-py3-none-any.whl", hash = "sha256:d16b43872fc4dfa1e60996334dcd0dc3e3f1f64161e2332bc1873b9f65754e6b", size = 15067340, upload-time = "2026-01-28T14:45:04.271Z" }, ] [[package]] @@ -2356,15 +2463,15 @@ wheels = [ [[package]] name = "rich" -version = "14.1.0" +version = "14.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, ] [[package]] @@ -2430,28 +2537,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] [[package]] @@ -2717,27 +2823,28 @@ wheels = [ [[package]] name = "tokenizers" -version = "0.21.4" +version = "0.22.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/2f/402986d0823f8d7ca139d969af2917fefaa9b947d1fb32f6168c509f2492/tokenizers-0.21.4.tar.gz", hash = "sha256:fa23f85fbc9a02ec5c6978da172cdcbac23498c3ca9f3645c5c68740ac007880", size = 351253, upload-time = "2025-07-28T15:48:54.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/c6/fdb6f72bf6454f52eb4a2510be7fb0f614e541a2554d6210e370d85efff4/tokenizers-0.21.4-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ccc10a7c3bcefe0f242867dc914fc1226ee44321eb618cfe3019b5df3400133", size = 2863987, upload-time = "2025-07-28T15:48:44.877Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a6/28975479e35ddc751dc1ddc97b9b69bf7fcf074db31548aab37f8116674c/tokenizers-0.21.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5e2f601a8e0cd5be5cc7506b20a79112370b9b3e9cb5f13f68ab11acd6ca7d60", size = 2732457, upload-time = "2025-07-28T15:48:43.265Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8f/24f39d7b5c726b7b0be95dca04f344df278a3fe3a4deb15a975d194cbb32/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b376f5a1aee67b4d29032ee85511bbd1b99007ec735f7f35c8a2eb104eade5", size = 3012624, upload-time = "2025-07-28T13:22:43.895Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/26358925717687a58cb74d7a508de96649544fad5778f0cd9827398dc499/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2107ad649e2cda4488d41dfd031469e9da3fcbfd6183e74e4958fa729ffbf9c6", size = 2939681, upload-time = "2025-07-28T13:22:47.499Z" }, - { url = "https://files.pythonhosted.org/packages/99/6f/cc300fea5db2ab5ddc2c8aea5757a27b89c84469899710c3aeddc1d39801/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c73012da95afafdf235ba80047699df4384fdc481527448a078ffd00e45a7d9", size = 3247445, upload-time = "2025-07-28T15:48:39.711Z" }, - { url = "https://files.pythonhosted.org/packages/be/bf/98cb4b9c3c4afd8be89cfa6423704337dc20b73eb4180397a6e0d456c334/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f23186c40395fc390d27f519679a58023f368a0aad234af145e0f39ad1212732", size = 3428014, upload-time = "2025-07-28T13:22:49.569Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/96c1cc780e6ca7f01a57c13235dd05b7bc1c0f3588512ebe9d1331b5f5ae/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc88bb34e23a54cc42713d6d98af5f1bf79c07653d24fe984d2d695ba2c922a2", size = 3193197, upload-time = "2025-07-28T13:22:51.471Z" }, - { url = "https://files.pythonhosted.org/packages/f2/90/273b6c7ec78af547694eddeea9e05de771278bd20476525ab930cecaf7d8/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51b7eabb104f46c1c50b486520555715457ae833d5aee9ff6ae853d1130506ff", size = 3115426, upload-time = "2025-07-28T15:48:41.439Z" }, - { url = "https://files.pythonhosted.org/packages/91/43/c640d5a07e95f1cf9d2c92501f20a25f179ac53a4f71e1489a3dcfcc67ee/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:714b05b2e1af1288bd1bc56ce496c4cebb64a20d158ee802887757791191e6e2", size = 9089127, upload-time = "2025-07-28T15:48:46.472Z" }, - { url = "https://files.pythonhosted.org/packages/44/a1/dd23edd6271d4dca788e5200a807b49ec3e6987815cd9d0a07ad9c96c7c2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1340ff877ceedfa937544b7d79f5b7becf33a4cfb58f89b3b49927004ef66f78", size = 9055243, upload-time = "2025-07-28T15:48:48.539Z" }, - { url = "https://files.pythonhosted.org/packages/21/2b/b410d6e9021c4b7ddb57248304dc817c4d4970b73b6ee343674914701197/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c1f4317576e465ac9ef0d165b247825a2a4078bcd01cba6b54b867bdf9fdd8b", size = 9298237, upload-time = "2025-07-28T15:48:50.443Z" }, - { url = "https://files.pythonhosted.org/packages/b7/0a/42348c995c67e2e6e5c89ffb9cfd68507cbaeb84ff39c49ee6e0a6dd0fd2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c212aa4e45ec0bb5274b16b6f31dd3f1c41944025c2358faaa5782c754e84c24", size = 9461980, upload-time = "2025-07-28T15:48:52.325Z" }, - { url = "https://files.pythonhosted.org/packages/3d/d3/dacccd834404cd71b5c334882f3ba40331ad2120e69ded32cf5fda9a7436/tokenizers-0.21.4-cp39-abi3-win32.whl", hash = "sha256:6c42a930bc5f4c47f4ea775c91de47d27910881902b0f20e4990ebe045a415d0", size = 2329871, upload-time = "2025-07-28T15:48:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/41/f2/fd673d979185f5dcbac4be7d09461cbb99751554ffb6718d0013af8604cb/tokenizers-0.21.4-cp39-abi3-win_amd64.whl", hash = "sha256:475d807a5c3eb72c59ad9b5fcdb254f6e17f53dfcbb9903233b0dfa9c943b597", size = 2507568, upload-time = "2025-07-28T15:48:55.456Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] @@ -2803,32 +2910,32 @@ wheels = [ [[package]] name = "types-pyyaml" -version = "6.0.12.20250822" +version = "6.0.12.20250915" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/85/90a442e538359ab5c9e30de415006fb22567aa4301c908c09f19e42975c2/types_pyyaml-6.0.12.20250822.tar.gz", hash = "sha256:259f1d93079d335730a9db7cff2bcaf65d7e04b4a56b5927d49a612199b59413", size = 17481, upload-time = "2025-08-22T03:02:16.209Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/8e/8f0aca667c97c0d76024b37cffa39e76e2ce39ca54a38f285a64e6ae33ba/types_pyyaml-6.0.12.20250822-py3-none-any.whl", hash = "sha256:1fe1a5e146aa315483592d292b72a172b65b946a6d98aa6ddd8e4aa838ab7098", size = 20314, upload-time = "2025-08-22T03:02:15.002Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, ] [[package]] name = "types-requests" -version = "2.32.4.20250809" +version = "2.32.4.20260107" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" }, + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, ] [[package]] name = "types-setuptools" -version = "80.9.0.20250822" +version = "82.0.0.20260210" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/90/796ac8c774a7f535084aacbaa6b7053d16fff5c630eff87c3ecff7896c37/types_setuptools-82.0.0.20260210.tar.gz", hash = "sha256:d9719fbbeb185254480ade1f25327c4654f8c00efda3fec36823379cebcdee58", size = 44768, upload-time = "2026-02-10T04:22:02.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/3e/54/3489432b1d9bc713c9d8aa810296b8f5b0088403662959fb63a8acdbd4fc/types_setuptools-82.0.0.20260210-py3-none-any.whl", hash = "sha256:5124a7daf67f195c6054e0f00f1d97c69caad12fdcf9113eba33eff0bce8cd2b", size = 68433, upload-time = "2026-02-10T04:22:00.876Z" }, ] [[package]] @@ -2851,23 +2958,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -2881,15 +2988,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] [package.optional-dependencies] diff --git a/mise.toml b/mise.toml index 0e7237be20..5e3088974c 100644 --- a/mise.toml +++ b/mise.toml @@ -14,15 +14,15 @@ config_roots = [ ] [tools] -node = "24.13.0" +node = "24.13.1" flutter = "3.35.7" -pnpm = "10.28.0" +pnpm = "10.29.3" terragrunt = "0.98.0" opentofu = "1.11.4" java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] -version = "1.30.0" +version = "1.35.1" bin = "dcm" postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm" @@ -37,13 +37,12 @@ run = "pnpm install --filter @immich/sdk --frozen-lockfile" [tasks."sdk:build"] dir = "open-api/typescript-sdk" -env._.path = "./node_modules/.bin" -run = "tsc" +run = "pnpm run build" # i18n tasks [tasks."i18n:format"] dir = "i18n" -run = { task = ":i18n:format-fix" } +run = "pnpm run format" [tasks."i18n:format-fix"] dir = "i18n" diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index eacf75b7ed..db3859ab6e 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -117,7 +117,7 @@ android:pathPrefix="/albums/" /> + android:pathPrefix="/people/" /> diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index 50ff11b0c2..64e67cbfee 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -48,7 +48,6 @@ fun Bitmap.toNativeBuffer(): Map { try { val buffer = NativeBuffer.wrap(pointer, size) copyPixelsToBuffer(buffer) - recycle() return mapOf( "pointer" to pointer, "width" to width.toLong(), @@ -57,8 +56,9 @@ fun Bitmap.toNativeBuffer(): Map { ) } catch (e: Exception) { NativeBuffer.free(pointer) - recycle() throw e + } finally { + recycle() } } diff --git a/mobile/bin/generate_keys.dart b/mobile/bin/generate_keys.dart index 8353b1c6f4..3c5c284c3e 100644 --- a/mobile/bin/generate_keys.dart +++ b/mobile/bin/generate_keys.dart @@ -3,7 +3,99 @@ import 'dart:convert'; import 'dart:io'; -const _kReservedWords = ['continue']; +const _kReservedWords = [ + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +]; + +const _kIntParamNames = [ + 'count', + 'number', + 'amount', + 'total', + 'index', + 'size', + 'length', + 'width', + 'height', + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'page', + 'limit', + 'offset', + 'max', + 'min', + 'id', + 'num', + 'quantity', +]; void main() async { final sourceFile = File('../i18n/en.json'); @@ -15,49 +107,258 @@ void main() async { final outputDir = Directory('lib/generated'); await outputDir.create(recursive: true); - final outputFile = File('lib/generated/intl_keys.g.dart'); - await _generate(sourceFile, outputFile); + final content = await sourceFile.readAsString(); + final translations = json.decode(content) as Map; + + final outputFile = File('lib/generated/translations.g.dart'); + await _generateTranslations(translations, outputFile); print('Generated ${outputFile.path}'); } -Future _generate(File source, File output) async { - final content = await source.readAsString(); - final translations = json.decode(content) as Map; +class TranslationNode { + final String key; + final String? value; + final Map children; + final List params; + + const TranslationNode({ + required this.key, + this.value, + Map? children, + List? params, + }) : children = children ?? const {}, + params = params ?? const []; + + bool get isLeaf => value != null; + bool get hasParams => params.isNotEmpty; +} + +class TranslationParam { + final String name; + final String type; + + const TranslationParam(this.name, this.type); +} + +Future _generateTranslations(Map translations, File output) async { + final root = _buildTranslationTree('', translations); final buffer = StringBuffer(''' // DO NOT EDIT. This is code generated via generate_keys.dart -abstract class IntlKeys { -'''); +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/message_format.dart'; - _writeKeys(buffer, translations); - buffer.writeln('}'); - - await output.writeAsString(buffer.toString()); +extension TranslationsExtension on BuildContext { + Translations get t => Translations.of(this); } -void _writeKeys( - StringBuffer buffer, - Map map, [ - String prefix = '', -]) { - for (final entry in map.entries) { - final key = entry.key; - final value = entry.value; +class StaticTranslations { + StaticTranslations._(); + static final instance = Translations._(null); +} - if (value is Map) { - _writeKeys(buffer, value, prefix.isEmpty ? key : '${prefix}_$key'); - } else { - final name = _cleanName(prefix.isEmpty ? key : '${prefix}_$key'); - final path = prefix.isEmpty ? key : '$prefix.$key'.replaceAll('_', '.'); - buffer.writeln(' static const $name = \'$path\';'); +abstract class _BaseTranslations { + BuildContext? get _context; + + String _t(String key, [Map? args]) { + if (key.isEmpty) return ''; + try { + final translated = key.tr(context: _context); + return args != null + ? MessageFormat(translated, locale: Intl.defaultLocale ?? 'en').format(args) + : translated; + } catch (e) { + return key; } } } -String _cleanName(String name) { - name = name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); - if (RegExp(r'^[0-9]').hasMatch(name)) name = 'k_$name'; - if (_kReservedWords.contains(name)) name = '${name}_'; +class Translations extends _BaseTranslations { + @override + final BuildContext? _context; + Translations._(this._context); + + static Translations of(BuildContext context) { + context.locale; + return Translations._(context); + } + +'''); + + _generateClassMembers(buffer, root, ' '); + buffer.writeln('}'); + _generateNestedClasses(buffer, root); + + await output.writeAsString(buffer.toString()); +} + +TranslationNode _buildTranslationTree(String key, dynamic value) { + if (value is Map) { + final children = {}; + for (final entry in value.entries) { + children[entry.key] = _buildTranslationTree(entry.key, entry.value); + } + return TranslationNode(key: key, children: children); + } else { + final stringValue = value.toString(); + final params = _extractParams(stringValue); + return TranslationNode(key: key, value: stringValue, params: params); + } +} + +List _extractParams(String value) { + final params = {}; + + final icuRegex = RegExp(r'\{(\w+),\s*(plural|select|number|date|time)([^}]*(?:\{[^}]*\}[^}]*)*)\}'); + for (final match in icuRegex.allMatches(value)) { + final name = match.group(1)!; + final icuType = match.group(2)!; + final icuContent = match.group(3) ?? ''; + + if (params.containsKey(name)) continue; + + String type; + if (icuType == 'plural' || icuType == 'number') { + type = 'int'; + } else if (icuType == 'select') { + final hasTrueFalse = RegExp(r',\s*(true|false)\s*\{').hasMatch(icuContent); + type = hasTrueFalse ? 'bool' : 'String'; + } else { + type = 'String'; + } + + params[name] = TranslationParam(name, type); + } + + var cleanedValue = value; + var depth = 0; + var icuStart = -1; + + for (var i = 0; i < value.length; i++) { + if (value[i] == '{') { + if (depth == 0) icuStart = i; + depth++; + } else if (value[i] == '}') { + depth--; + if (depth == 0 && icuStart >= 0) { + final block = value.substring(icuStart, i + 1); + if (RegExp(r'^\{\w+,').hasMatch(block)) { + cleanedValue = cleanedValue.replaceFirst(block, ''); + } + icuStart = -1; + } + } + } + + final simpleRegex = RegExp(r'\{(\w+)\}'); + for (final match in simpleRegex.allMatches(cleanedValue)) { + final name = match.group(1)!; + + if (params.containsKey(name)) continue; + + String type; + if (_kIntParamNames.contains(name.toLowerCase())) { + type = 'int'; + } else { + type = 'Object'; + } + + params[name] = TranslationParam(name, type); + } + + return params.values.toList(); +} + +void _generateClassMembers(StringBuffer buffer, TranslationNode node, String indent, [String keyPrefix = '']) { + final sortedKeys = node.children.keys.toList()..sort(); + + for (final childKey in sortedKeys) { + final child = node.children[childKey]!; + final dartName = _escapeName(childKey); + final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey'; + + if (child.isLeaf) { + if (child.hasParams) { + _generateMethod(buffer, dartName, fullKey, child.params, indent); + } else { + _generateGetter(buffer, dartName, fullKey, indent); + } + } else { + final className = _toNestedClassName(keyPrefix, childKey); + buffer.writeln('${indent}late final $dartName = $className._(_context);'); + } + } +} + +void _generateGetter(StringBuffer buffer, String dartName, String translationKey, String indent) { + buffer.writeln('${indent}String get $dartName => _t(\'$translationKey\');'); +} + +void _generateMethod( + StringBuffer buffer, + String dartName, + String translationKey, + List params, + String indent, +) { + final paramList = params.map((p) => 'required ${p.type} ${_escapeName(p.name)}').join(', '); + final argsMap = params.map((p) => '\'${p.name}\': ${_escapeName(p.name)}').join(', '); + buffer.writeln('${indent}String $dartName({$paramList}) => _t(\'$translationKey\', {$argsMap});'); +} + +void _generateNestedClasses(StringBuffer buffer, TranslationNode node, [String keyPrefix = '']) { + final sortedKeys = node.children.keys.toList()..sort(); + + for (final childKey in sortedKeys) { + final child = node.children[childKey]!; + final fullKey = keyPrefix.isEmpty ? childKey : '$keyPrefix.$childKey'; + + if (!child.isLeaf && child.children.isNotEmpty) { + final className = _toNestedClassName(keyPrefix, childKey); + buffer.writeln(); + buffer.writeln('class $className extends _BaseTranslations {'); + buffer.writeln(' @override'); + buffer.writeln(' final BuildContext? _context;'); + buffer.writeln(' $className._(this._context);'); + _generateClassMembers(buffer, child, ' ', fullKey); + buffer.writeln('}'); + _generateNestedClasses(buffer, child, fullKey); + } + } +} + +String _toNestedClassName(String prefix, String key) { + final parts = []; + if (prefix.isNotEmpty) { + parts.addAll(prefix.split('.')); + } + parts.add(key); + + final result = StringBuffer('_'); + for (final part in parts) { + final words = part.split('_'); + for (final word in words) { + if (word.isNotEmpty) { + result.write(word[0].toUpperCase()); + if (word.length > 1) { + result.write(word.substring(1).toLowerCase()); + } + } + } + } + result.write('Translations'); + + return result.toString(); +} + +String _escapeName(String name) { + if (_kReservedWords.contains(name)) { + return '$name\$'; + } + if (RegExp(r'^[0-9]').hasMatch(name)) { + return 'k$name'; + } return name; } diff --git a/mobile/dcm_global.yaml b/mobile/dcm_global.yaml index c33846e674..ffe77eede8 100644 --- a/mobile/dcm_global.yaml +++ b/mobile/dcm_global.yaml @@ -1 +1 @@ -version: '>=1.29.0 <=1.30.0' +version: '>=1.29.0 <=1.36.0' diff --git a/mobile/drift_schemas/main/drift_schema_v19.json b/mobile/drift_schemas/main/drift_schema_v19.json new file mode 100644 index 0000000000..405650a41f --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v19.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_edited","getter_name":"isEdited","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_edited\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_edited\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[6],"type":"index","data":{"on":6,"name":"idx_local_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_remote_album_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)","unique":false,"columns":[]}},{"id":9,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":10,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":11,"references":[2],"type":"index","data":{"on":2,"name":"idx_stack_primary_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)","unique":false,"columns":[]}},{"id":12,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":13,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":14,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":15,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":16,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_stack_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)","unique":false,"columns":[]}},{"id":17,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_day","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))","unique":false,"columns":[]}},{"id":18,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_month","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))","unique":false,"columns":[]}},{"id":19,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":21,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":22,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":23,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":24,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":25,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":26,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":27,"references":[1,26],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":28,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":29,"references":[1,28],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":30,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":31,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":32,"references":[21],"type":"index","data":{"on":21,"name":"idx_partner_shared_with_id","sql":"CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)","unique":false,"columns":[]}},{"id":33,"references":[22],"type":"index","data":{"on":22,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":34,"references":[23],"type":"index","data":{"on":23,"name":"idx_remote_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":35,"references":[25],"type":"index","data":{"on":25,"name":"idx_remote_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)","unique":false,"columns":[]}},{"id":36,"references":[28],"type":"index","data":{"on":28,"name":"idx_person_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)","unique":false,"columns":[]}},{"id":37,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_person_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)","unique":false,"columns":[]}},{"id":38,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)","unique":false,"columns":[]}},{"id":39,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":40,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}}]} \ No newline at end of file diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index fc9cebc80f..9bbe00852e 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event { } // Asset Viewer Events -class ViewerOpenBottomSheetEvent extends Event { - final bool activitiesMode; - const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); +class ViewerShowDetailsEvent extends Event { + const ViewerShowDetailsEvent(); } class ViewerReloadAssetEvent extends Event { diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index f6bed7cf61..00545aa01a 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -73,6 +73,9 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), + // Image viewer navigation settings + tapToNavigate._(141), + // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), diff --git a/mobile/lib/domain/models/tag.model.dart b/mobile/lib/domain/models/tag.model.dart new file mode 100644 index 0000000000..357367b13e --- /dev/null +++ b/mobile/lib/domain/models/tag.model.dart @@ -0,0 +1,29 @@ +import 'package:openapi/api.dart'; + +class Tag { + final String id; + final String value; + + const Tag({required this.id, required this.value}); + + @override + String toString() { + return 'Tag(id: $id, value: $value)'; + } + + @override + bool operator ==(covariant Tag other) { + if (identical(this, other)) return true; + + return other.id == id && other.value == value; + } + + @override + int get hashCode { + return id.hashCode ^ value.hashCode; + } + + static Tag fromDto(TagResponseDto dto) { + return Tag(id: dto.id, value: dto.value); + } +} diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index d45f710d7b..ecfe83e5cb 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -10,6 +10,10 @@ class DriftPeopleService { const DriftPeopleService(this._repository, this._personApiRepository); + Future get(String personId) { + return _repository.get(personId); + } + Future> getAssetPeople(String assetId) { return _repository.getAssetPeople(assetId); } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index bd36d0b569..39aeb867a3 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -183,8 +183,8 @@ class TimelineService { return _buffer.slice(start, start + count); } - // Pre-cache assets around the given index for asset viewer - Future preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); + // Preload assets around the given index for asset viewer + Future preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length)); diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 169032ff5d..5917e127bc 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { damping: 80, ); } + +class SnapScrollPhysics extends ScrollPhysics { + static const _minFlingVelocity = 700.0; + static const minSnapDistance = 30.0; + + static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300); + + const SnapScrollPhysics({super.parent}); + + @override + SnapScrollPhysics applyTo(ScrollPhysics? ancestor) { + return SnapScrollPhysics(parent: buildParent(ancestor)); + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + assert( + position is SnapScrollPosition, + 'SnapScrollPhysics can only be used with Scrollables that use a ' + 'controller whose createScrollPosition returns a SnapScrollPosition', + ); + + final snapOffset = (position as SnapScrollPosition).snapOffset; + if (snapOffset <= 0) { + return super.createBallisticSimulation(position, velocity); + } + + if (position.pixels >= snapOffset) { + final simulation = super.createBallisticSimulation(position, velocity); + if (simulation == null || simulation.x(double.infinity) >= snapOffset) { + return simulation; + } + } + + return ScrollSpringSimulation( + _spring, + position.pixels, + target(position, velocity, snapOffset), + velocity, + tolerance: toleranceFor(position), + ); + } + + static double target(ScrollMetrics position, double velocity, double snapOffset) { + if (velocity > _minFlingVelocity) return snapOffset; + if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset; + return position.pixels < minSnapDistance ? 0.0 : snapOffset; + } +} + +class SnapScrollPosition extends ScrollPositionWithSingleContext { + double snapOffset; + + SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition}); +} + +class ProxyScrollController extends ScrollController { + final ScrollController scrollController; + + ProxyScrollController({required this.scrollController}); + + SnapScrollPosition get snapPosition => position as SnapScrollPosition; + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { + return ProxyScrollPosition( + scrollController: scrollController, + physics: physics, + context: context, + oldPosition: oldPosition, + ); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } +} + +class ProxyScrollPosition extends SnapScrollPosition { + final ScrollController scrollController; + + ProxyScrollPosition({ + required this.scrollController, + required super.physics, + required super.context, + super.oldPosition, + }); + + @override + double setPixels(double newPixels) { + final overscroll = super.setPixels(newPixels); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + return overscroll; + } + + @override + void forcePixels(double value) { + super.forcePixels(value); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + } + + @override + double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.maxScrollExtent + : super.maxScrollExtent; + + @override + double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.minScrollExtent + : super.minScrollExtent; + + @override + double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension + ? scrollController.position.viewportDimension + : super.viewportDimension; +} diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.dart b/mobile/lib/infrastructure/entities/asset_face.entity.dart index 5f793030c3..45a0b436bd 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.dart @@ -3,6 +3,8 @@ import 'package:immich_mobile/infrastructure/entities/person.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)') +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)') class AssetFaceEntity extends Table with DriftDefaultsMixin { const AssetFaceEntity(); diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart index 092fcc5859..7f2f3825e3 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart @@ -588,6 +588,10 @@ typedef $$AssetFaceEntityTableProcessedTableManager = i1.AssetFaceEntityData, i0.PrefetchHooks Function({bool assetId, bool personId}) >; +i0.Index get idxAssetFacePersonId => i0.Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', +); class $AssetFaceEntityTable extends i2.AssetFaceEntity with i0.TableInfo<$AssetFaceEntityTable, i1.AssetFaceEntityData> { @@ -1207,3 +1211,8 @@ class AssetFaceEntityCompanion .toString(); } } + +i0.Index get idxAssetFaceAssetId => i0.Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', +); diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart index 53f1a10662..b0f4b1b27f 100644 --- a/mobile/lib/infrastructure/entities/local_album_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.dart @@ -3,6 +3,9 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql( + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', +) class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin { const LocalAlbumAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart index 70c298332b..77b2798afb 100644 --- a/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/local_album_asset.entity.drift.dart @@ -459,6 +459,10 @@ typedef $$LocalAlbumAssetEntityTableProcessedTableManager = i1.LocalAlbumAssetEntityData, i0.PrefetchHooks Function({bool assetId, bool albumId}) >; +i0.Index get idxLocalAlbumAssetAlbumAsset => i0.Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', +); class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity with diff --git a/mobile/lib/infrastructure/entities/partner.entity.dart b/mobile/lib/infrastructure/entities/partner.entity.dart index dbc675ee99..1d8dc6d87c 100644 --- a/mobile/lib/infrastructure/entities/partner.entity.dart +++ b/mobile/lib/infrastructure/entities/partner.entity.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)') class PartnerEntity extends Table with DriftDefaultsMixin { const PartnerEntity(); diff --git a/mobile/lib/infrastructure/entities/partner.entity.drift.dart b/mobile/lib/infrastructure/entities/partner.entity.drift.dart index 01ec72fe23..76a91f27bf 100644 --- a/mobile/lib/infrastructure/entities/partner.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/partner.entity.drift.dart @@ -440,6 +440,10 @@ typedef $$PartnerEntityTableProcessedTableManager = i1.PartnerEntityData, i0.PrefetchHooks Function({bool sharedById, bool sharedWithId}) >; +i0.Index get idxPartnerSharedWithId => i0.Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', +); class $PartnerEntityTable extends i2.PartnerEntity with i0.TableInfo<$PartnerEntityTable, i1.PartnerEntityData> { diff --git a/mobile/lib/infrastructure/entities/person.entity.dart b/mobile/lib/infrastructure/entities/person.entity.dart index f0878e00f8..6e014590ab 100644 --- a/mobile/lib/infrastructure/entities/person.entity.dart +++ b/mobile/lib/infrastructure/entities/person.entity.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)') class PersonEntity extends Table with DriftDefaultsMixin { const PersonEntity(); diff --git a/mobile/lib/infrastructure/entities/person.entity.drift.dart b/mobile/lib/infrastructure/entities/person.entity.drift.dart index ffbd796f4b..02ea48c846 100644 --- a/mobile/lib/infrastructure/entities/person.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/person.entity.drift.dart @@ -455,6 +455,10 @@ typedef $$PersonEntityTableProcessedTableManager = i1.PersonEntityData, i0.PrefetchHooks Function({bool ownerId}) >; +i0.Index get idxPersonOwnerId => i0.Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', +); class $PersonEntityTable extends i2.PersonEntity with i0.TableInfo<$PersonEntityTable, i1.PersonEntityData> { diff --git a/mobile/lib/infrastructure/entities/remote_album.entity.dart b/mobile/lib/infrastructure/entities/remote_album.entity.dart index 74b00dd9ee..30e13853d8 100644 --- a/mobile/lib/infrastructure/entities/remote_album.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_album.entity.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)') class RemoteAlbumEntity extends Table with DriftDefaultsMixin { const RemoteAlbumEntity(); diff --git a/mobile/lib/infrastructure/entities/remote_album.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_album.entity.drift.dart index 30a6d0b535..7dc864b978 100644 --- a/mobile/lib/infrastructure/entities/remote_album.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_album.entity.drift.dart @@ -566,6 +566,10 @@ typedef $$RemoteAlbumEntityTableProcessedTableManager = i1.RemoteAlbumEntityData, i0.PrefetchHooks Function({bool ownerId, bool thumbnailAssetId}) >; +i0.Index get idxRemoteAlbumOwnerId => i0.Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', +); class $RemoteAlbumEntityTable extends i3.RemoteAlbumEntity with i0.TableInfo<$RemoteAlbumEntityTable, i1.RemoteAlbumEntityData> { diff --git a/mobile/lib/infrastructure/entities/remote_album_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_album_asset.entity.dart index e99f5364a4..6d1e88514b 100644 --- a/mobile/lib/infrastructure/entities/remote_album_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_album_asset.entity.dart @@ -3,6 +3,9 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql( + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', +) class RemoteAlbumAssetEntity extends Table with DriftDefaultsMixin { const RemoteAlbumAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/remote_album_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_album_asset.entity.drift.dart index adf22635c1..a03c4d7e96 100644 --- a/mobile/lib/infrastructure/entities/remote_album_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_album_asset.entity.drift.dart @@ -441,6 +441,10 @@ typedef $$RemoteAlbumAssetEntityTableProcessedTableManager = i1.RemoteAlbumAssetEntityData, i0.PrefetchHooks Function({bool assetId, bool albumId}) >; +i0.Index get idxRemoteAlbumAssetAlbumAsset => i0.Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', +); class $RemoteAlbumAssetEntityTable extends i2.RemoteAlbumAssetEntity with diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 4dc0fa568f..4c8b563616 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -19,6 +19,13 @@ ON remote_asset_entity (owner_id, library_id, checksum) WHERE (library_id IS NOT NULL); ''') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)') +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)') +@TableIndex.sql( + "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))", +) +@TableIndex.sql( + "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))", +) class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const RemoteAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index 2d9e8b235e..8231cfcd8a 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart @@ -1710,3 +1710,15 @@ i0.Index get idxRemoteAssetChecksum => i0.Index( 'idx_remote_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', ); +i0.Index get idxRemoteAssetStackId => i0.Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', +); +i0.Index get idxRemoteAssetLocalDateTimeDay => i0.Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', +); +i0.Index get idxRemoteAssetLocalDateTimeMonth => i0.Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', +); diff --git a/mobile/lib/infrastructure/entities/stack.entity.dart b/mobile/lib/infrastructure/entities/stack.entity.dart index be50d7e330..4f90845a45 100644 --- a/mobile/lib/infrastructure/entities/stack.entity.dart +++ b/mobile/lib/infrastructure/entities/stack.entity.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)') class StackEntity extends Table with DriftDefaultsMixin { const StackEntity(); diff --git a/mobile/lib/infrastructure/entities/stack.entity.drift.dart b/mobile/lib/infrastructure/entities/stack.entity.drift.dart index ff7a3c3444..55017f8344 100644 --- a/mobile/lib/infrastructure/entities/stack.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/stack.entity.drift.dart @@ -357,6 +357,10 @@ typedef $$StackEntityTableProcessedTableManager = i1.StackEntityData, i0.PrefetchHooks Function({bool ownerId}) >; +i0.Index get idxStackPrimaryAssetId => i0.Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', +); class $StackEntityTable extends i2.StackEntity with i0.TableInfo<$StackEntityTable, i1.StackEntityData> { diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 652e9de943..5495d21bd3 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 18; + int get schemaVersion => 19; @override MigrationStrategy get migration => MigrationStrategy( @@ -213,6 +213,19 @@ class Drift extends $Drift implements IDatabaseRepository { from17To18: (m, v18) async { await m.createIndex(v18.idxRemoteAssetCloudId); }, + from18To19: (m, v19) async { + await m.createIndex(v19.idxAssetFacePersonId); + await m.createIndex(v19.idxAssetFaceAssetId); + await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset); + await m.createIndex(v19.idxPartnerSharedWithId); + await m.createIndex(v19.idxPersonOwnerId); + await m.createIndex(v19.idxRemoteAlbumOwnerId); + await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset); + await m.createIndex(v19.idxRemoteAssetStackId); + await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay); + await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth); + await m.createIndex(v19.idxStackPrimaryAssetId); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index c561eef0c6..ae805ad25e 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -100,12 +100,18 @@ abstract class $Drift extends i0.GeneratedDatabase { remoteAlbumEntity, localAlbumEntity, localAlbumAssetEntity, + i7.idxLocalAlbumAssetAlbumAsset, + i5.idxRemoteAlbumOwnerId, i4.idxLocalAssetChecksum, i4.idxLocalAssetCloudId, + i3.idxStackPrimaryAssetId, i2.idxRemoteAssetOwnerChecksum, i2.uQRemoteAssetsOwnerChecksum, i2.uQRemoteAssetsOwnerLibraryChecksum, i2.idxRemoteAssetChecksum, + i2.idxRemoteAssetStackId, + i2.idxRemoteAssetLocalDateTimeDay, + i2.idxRemoteAssetLocalDateTimeMonth, authUserEntity, userMetadataEntity, partnerEntity, @@ -119,8 +125,13 @@ abstract class $Drift extends i0.GeneratedDatabase { assetFaceEntity, storeEntity, trashedLocalAssetEntity, + i10.idxPartnerSharedWithId, i11.idxLatLng, + i12.idxRemoteAlbumAssetAlbumAsset, i14.idxRemoteAssetCloudId, + i17.idxPersonOwnerId, + i18.idxAssetFacePersonId, + i18.idxAssetFaceAssetId, i20.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetAlbum, ]; diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 72601f249f..e56eb97c75 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -7857,6 +7857,509 @@ final class Schema18 extends i0.VersionedSchema { ); } +final class Schema19 extends i0.VersionedSchema { + Schema19({required super.database}) : super(version: 19); + @override + late final List entities = [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + ]; + late final Shape20 userEntity = Shape20( + source: i0.VersionedTable( + entityName: 'user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_3, + _column_84, + _column_85, + _column_91, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape28 remoteAssetEntity = Shape28( + source: i0.VersionedTable( + entityName: 'remote_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + _column_86, + _column_101, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 stackEntity = Shape3( + source: i0.VersionedTable( + entityName: 'stack_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_0, _column_9, _column_5, _column_15, _column_75], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape26 localAssetEntity = Shape26( + source: i0.VersionedTable( + entityName: 'local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_22, + _column_14, + _column_23, + _column_98, + _column_96, + _column_46, + _column_47, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape9 remoteAlbumEntity = Shape9( + source: i0.VersionedTable( + entityName: 'remote_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_56, + _column_9, + _column_5, + _column_15, + _column_57, + _column_58, + _column_59, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape19 localAlbumEntity = Shape19( + source: i0.VersionedTable( + entityName: 'local_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_5, + _column_31, + _column_32, + _column_90, + _column_33, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape22 localAlbumAssetEntity = Shape22( + source: i0.VersionedTable( + entityName: 'local_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, album_id)'], + columns: [_column_34, _column_35, _column_33], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + final i1.Index idxRemoteAlbumOwnerId = i1.Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + final i1.Index idxLocalAssetChecksum = i1.Index( + 'idx_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)', + ); + final i1.Index idxLocalAssetCloudId = i1.Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', + ); + final i1.Index idxStackPrimaryAssetId = i1.Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', + ); + final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( + 'idx_remote_asset_owner_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', + ); + final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', + ); + final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index( + 'UQ_remote_assets_owner_library_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', + ); + final i1.Index idxRemoteAssetChecksum = i1.Index( + 'idx_remote_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', + ); + final i1.Index idxRemoteAssetStackId = i1.Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + late final Shape21 authUserEntity = Shape21( + source: i0.VersionedTable( + entityName: 'auth_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_3, + _column_2, + _column_84, + _column_85, + _column_92, + _column_93, + _column_7, + _column_94, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 userMetadataEntity = Shape4( + source: i0.VersionedTable( + entityName: 'user_metadata_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(user_id, "key")'], + columns: [_column_25, _column_26, _column_27], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape5 partnerEntity = Shape5( + source: i0.VersionedTable( + entityName: 'partner_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'], + columns: [_column_28, _column_29, _column_30], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape8 remoteExifEntity = Shape8( + source: i0.VersionedTable( + entityName: 'remote_exif_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id)'], + columns: [ + _column_36, + _column_37, + _column_38, + _column_39, + _column_40, + _column_41, + _column_11, + _column_10, + _column_42, + _column_43, + _column_44, + _column_45, + _column_46, + _column_47, + _column_48, + _column_49, + _column_50, + _column_51, + _column_52, + _column_53, + _column_54, + _column_55, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape7 remoteAlbumAssetEntity = Shape7( + source: i0.VersionedTable( + entityName: 'remote_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, album_id)'], + columns: [_column_36, _column_60], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape10 remoteAlbumUserEntity = Shape10( + source: i0.VersionedTable( + entityName: 'remote_album_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(album_id, user_id)'], + columns: [_column_60, _column_25, _column_61], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape27 remoteAssetCloudIdEntity = Shape27( + source: i0.VersionedTable( + entityName: 'remote_asset_cloud_id_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id)'], + columns: [ + _column_36, + _column_99, + _column_100, + _column_96, + _column_46, + _column_47, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape11 memoryEntity = Shape11( + source: i0.VersionedTable( + entityName: 'memory_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_9, + _column_5, + _column_18, + _column_15, + _column_8, + _column_62, + _column_63, + _column_64, + _column_65, + _column_66, + _column_67, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape12 memoryAssetEntity = Shape12( + source: i0.VersionedTable( + entityName: 'memory_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'], + columns: [_column_36, _column_68], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape14 personEntity = Shape14( + source: i0.VersionedTable( + entityName: 'person_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_1, + _column_69, + _column_71, + _column_72, + _column_73, + _column_74, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape15 assetFaceEntity = Shape15( + source: i0.VersionedTable( + entityName: 'asset_face_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_36, + _column_76, + _column_77, + _column_78, + _column_79, + _column_80, + _column_81, + _column_82, + _column_83, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape18 storeEntity = Shape18( + source: i0.VersionedTable( + entityName: 'store_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_87, _column_88, _column_89], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape25 trashedLocalAssetEntity = Shape25( + source: i0.VersionedTable( + entityName: 'trashed_local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id, album_id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_95, + _column_22, + _column_14, + _column_23, + _column_97, + ], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxPartnerSharedWithId = i1.Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + final i1.Index idxLatLng = i1.Index( + 'idx_lat_lng', + 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', + ); + final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + final i1.Index idxRemoteAssetCloudId = i1.Index( + 'idx_remote_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)', + ); + final i1.Index idxPersonOwnerId = i1.Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + final i1.Index idxAssetFacePersonId = i1.Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + final i1.Index idxAssetFaceAssetId = i1.Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', + ); + final i1.Index idxTrashedLocalAssetChecksum = i1.Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', + ); + final i1.Index idxTrashedLocalAssetAlbum = i1.Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', + ); +} + i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -7875,6 +8378,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema16 schema) from15To16, required Future Function(i1.Migrator m, Schema17 schema) from16To17, required Future Function(i1.Migrator m, Schema18 schema) from17To18, + required Future Function(i1.Migrator m, Schema19 schema) from18To19, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -7963,6 +8467,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from17To18(migrator, schema); return 18; + case 18: + final schema = Schema19(database: database); + final migrator = i1.Migrator(database, schema); + await from18To19(migrator, schema); + return 19; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -7987,6 +8496,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema16 schema) from15To16, required Future Function(i1.Migrator m, Schema17 schema) from16To17, required Future Function(i1.Migrator m, Schema18 schema) from17To18, + required Future Function(i1.Migrator m, Schema19 schema) from18To19, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -8006,5 +8516,6 @@ i1.OnUpgrade stepByStep({ from15To16: from15To16, from16To17: from16To17, from17To18: from17To18, + from18To19: from18To19, ), ); diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index e2b8646dba..40402b6f72 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -7,6 +8,13 @@ class DriftPeopleRepository extends DriftDatabaseRepository { final Drift _db; const DriftPeopleRepository(this._db) : super(_db); + Future get(String personId) async { + final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId)); + + final result = await query.getSingleOrNull(); + return result?.toDto(); + } + Future> getAssetPeople(String assetId) async { final query = _db.select(_db.assetFaceEntity).join([ innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)), @@ -19,19 +27,28 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } Future> getAllPeople() async { + final people = _db.personEntity; + final faces = _db.assetFaceEntity; + final assets = _db.remoteAssetEntity; + final query = - _db.select(_db.personEntity).join([ - leftOuterJoin(_db.assetFaceEntity, _db.assetFaceEntity.personId.equalsExp(_db.personEntity.id)), + _db.select(people).join([ + innerJoin(faces, faces.personId.equalsExp(people.id)), + innerJoin(assets, assets.id.equalsExp(faces.assetId)), ]) - ..where(_db.personEntity.isHidden.equals(false)) - ..groupBy([_db.personEntity.id], having: _db.assetFaceEntity.id.count().isBiggerOrEqualValue(3)) + ..where( + people.isHidden.equals(false) & + assets.deletedAt.isNull() & + assets.visibility.equalsValue(AssetVisibility.timeline), + ) + ..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not()) ..orderBy([ - OrderingTerm(expression: _db.personEntity.name.equals('').not(), mode: OrderingMode.desc), - OrderingTerm(expression: _db.assetFaceEntity.id.count(), mode: OrderingMode.desc), + OrderingTerm(expression: people.name.equals('').not(), mode: OrderingMode.desc), + OrderingTerm(expression: faces.id.count(), mode: OrderingMode.desc), ]); return query.map((row) { - final person = row.readTable(_db.personEntity); + final person = row.readTable(people); return person.toDto(); }).get(); } diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 043a42b1a4..bcfddfce6e 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -35,6 +35,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 100, @@ -59,6 +60,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 1000, diff --git a/mobile/lib/infrastructure/repositories/tags_api.repository.dart b/mobile/lib/infrastructure/repositories/tags_api.repository.dart new file mode 100644 index 0000000000..e81b79c459 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/tags_api.repository.dart @@ -0,0 +1,17 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:openapi/api.dart'; + +final tagsApiRepositoryProvider = Provider( + (ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi), +); + +class TagsApiRepository extends ApiRepository { + final TagsApi _api; + const TagsApiRepository(this._api); + + Future?> getAllTags() async { + return await _api.getAllTags(); + } +} diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 0e145395df..7544b4b2ac 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -203,7 +203,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { final album = albums.first; final isAscending = album.order == AlbumAssetOrder.asc; final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -361,7 +361,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -431,7 +431,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -501,7 +501,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -603,7 +603,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.localDateTime.dateFmt(groupBy); + final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy); final query = _db.remoteAssetEntity.selectOnly() ..addColumns([assetCountExp, dateExp]) @@ -678,6 +678,11 @@ extension on Expression { } } +extension on $RemoteAssetEntityTable { + Expression effectiveCreatedAt(GroupAssetsBy groupBy) => + coalesce([localDateTime.dateFmt(groupBy), createdAt.dateFmt(groupBy, toLocal: true)]); +} + extension on String { DateTime truncateDate(GroupAssetsBy groupBy) { final format = switch (groupBy) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 86b1c6cc5f..1316e66273 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -18,7 +18,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; @@ -219,8 +219,8 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ref .read(backgroundWorkerFgServiceProvider) .saveNotificationMessage( - IntlKeys.uploading_media.t(), - IntlKeys.backup_background_service_default_notification.t(), + StaticTranslations.instance.uploading_media, + StaticTranslations.instance.backup_background_service_default_notification, ); } } else { diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 2d45913fcb..1b730e0c68 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -214,6 +214,7 @@ class SearchFilter { String? ocr; String? language; String? assetId; + List? tagIds; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -231,6 +232,7 @@ class SearchFilter { this.ocr, this.language, this.assetId, + this.tagIds, required this.people, required this.location, required this.camera, @@ -246,6 +248,7 @@ class SearchFilter { (description == null || (description!.isEmpty)) && (assetId == null || (assetId!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) && + (tagIds ?? []).isEmpty && people.isEmpty && location.country == null && location.state == null && @@ -269,6 +272,7 @@ class SearchFilter { String? ocr, String? assetId, Set? people, + List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, @@ -290,12 +294,13 @@ class SearchFilter { display: display ?? this.display, rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, + tagIds: tagIds ?? this.tagIds, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -309,6 +314,7 @@ class SearchFilter { other.ocr == ocr && other.assetId == assetId && other.people == people && + other.tagIds == tagIds && other.location == location && other.camera == camera && other.date == date && @@ -326,6 +332,7 @@ class SearchFilter { ocr.hashCode ^ assetId.hashCode ^ people.hashCode ^ + tagIds.hashCode ^ location.hashCode ^ camera.hashCode ^ date.hashCode ^ diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index 049628a8d2..78a80c9013 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -6,6 +6,7 @@ class ServerFeatures { final bool oauthEnabled; final bool passwordLogin; final bool ocr; + final bool smartSearch; const ServerFeatures({ required this.trash, @@ -13,21 +14,30 @@ class ServerFeatures { required this.oauthEnabled, required this.passwordLogin, this.ocr = false, + this.smartSearch = false, }); - ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) { + ServerFeatures copyWith({ + bool? trash, + bool? map, + bool? oauthEnabled, + bool? passwordLogin, + bool? ocr, + bool? smartSearch, + }) { return ServerFeatures( trash: trash ?? this.trash, map: map ?? this.map, oauthEnabled: oauthEnabled ?? this.oauthEnabled, passwordLogin: passwordLogin ?? this.passwordLogin, ocr: ocr ?? this.ocr, + smartSearch: smartSearch ?? this.smartSearch, ); } @override String toString() { - return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)'; + return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)'; } ServerFeatures.fromDto(ServerFeaturesDto dto) @@ -35,7 +45,8 @@ class ServerFeatures { map = dto.map, oauthEnabled = dto.oauth, passwordLogin = dto.passwordLogin, - ocr = dto.ocr; + ocr = dto.ocr, + smartSearch = dto.smartSearch; @override bool operator ==(covariant ServerFeatures other) { @@ -45,11 +56,17 @@ class ServerFeatures { other.map == map && other.oauthEnabled == oauthEnabled && other.passwordLogin == passwordLogin && - other.ocr == ocr; + other.ocr == ocr && + other.smartSearch == smartSearch; } @override int get hashCode { - return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode; + return trash.hashCode ^ + map.hashCode ^ + oauthEnabled.hashCode ^ + passwordLogin.hashCode ^ + ocr.hashCode ^ + smartSearch.hashCode; } } diff --git a/mobile/lib/models/server_info/server_info.model.dart b/mobile/lib/models/server_info/server_info.model.dart index 5d78acb0b8..a039bb70eb 100644 --- a/mobile/lib/models/server_info/server_info.model.dart +++ b/mobile/lib/models/server_info/server_info.model.dart @@ -28,7 +28,7 @@ class ServerInfo { const ServerInfo({ required this.serverVersion, - required this.latestVersion, + this.latestVersion, required this.serverFeatures, required this.serverConfig, required this.serverDiskInfo, diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart index 57a1f441eb..4315cf616a 100644 --- a/mobile/lib/models/shared_link/shared_link.model.dart +++ b/mobile/lib/models/shared_link/shared_link.model.dart @@ -14,6 +14,7 @@ class SharedLink { final String key; final bool showMetadata; final SharedLinkSource type; + final String? slug; const SharedLink({ required this.id, @@ -27,6 +28,7 @@ class SharedLink { required this.key, required this.showMetadata, required this.type, + required this.slug, }); SharedLink copyWith({ @@ -41,6 +43,7 @@ class SharedLink { String? key, bool? showMetadata, SharedLinkSource? type, + String? slug, }) { return SharedLink( id: id ?? this.id, @@ -54,6 +57,7 @@ class SharedLink { key: key ?? this.key, showMetadata: showMetadata ?? this.showMetadata, type: type ?? this.type, + slug: slug ?? this.slug, ); } @@ -66,6 +70,7 @@ class SharedLink { expiresAt = dto.expiresAt, key = dto.key, showMetadata = dto.showMetadata, + slug = dto.slug, type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual, title = dto.type == SharedLinkType.ALBUM ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE" @@ -78,7 +83,7 @@ class SharedLink { @override String toString() => - 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)'; + 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)'; @override bool operator ==(Object other) => @@ -94,7 +99,8 @@ class SharedLink { other.expiresAt == expiresAt && other.key == key && other.showMetadata == showMetadata && - other.type == type; + other.type == type && + other.slug == slug; @override int get hashCode => @@ -108,5 +114,6 @@ class SharedLink { expiresAt.hashCode ^ key.hashCode ^ showMetadata.hashCode ^ - type.hashCode; + type.hashCode ^ + slug.hashCode; } diff --git a/mobile/lib/pages/album/album_options.page.dart b/mobile/lib/pages/album/album_options.page.dart index b0f682ffed..ca65a92a79 100644 --- a/mobile/lib/pages/album/album_options.page.dart +++ b/mobile/lib/pages/album/album_options.page.dart @@ -134,7 +134,7 @@ class AlbumOptionsPage extends HookConsumerWidget { itemBuilder: (context, index) { final user = sharedUsers.value[index]; return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), diff --git a/mobile/lib/pages/album/album_shared_user_icons.dart b/mobile/lib/pages/album/album_shared_user_icons.dart index fe1823ec61..7cf6f387ae 100644 --- a/mobile/lib/pages/album/album_shared_user_icons.dart +++ b/mobile/lib/pages/album/album_shared_user_icons.dart @@ -41,7 +41,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget { itemBuilder: ((context, index) { return Padding( padding: const EdgeInsets.only(right: 8.0), - child: UserCircleAvatar(user: sharedUsers.value[index], radius: 18, size: 36), + child: UserCircleAvatar(user: sharedUsers.value[index], size: 36), ); }), itemCount: sharedUsers.value.length, diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 440544f989..cd6c2a62b0 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -10,7 +10,7 @@ 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/extensions/translate_extensions.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; @@ -153,7 +153,7 @@ class _DriftBackupPageState extends ConsumerState { Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1), const SizedBox(width: 8), Text( - IntlKeys.backup_error_sync_failed.t(), + context.t.backup_error_sync_failed, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), textAlign: TextAlign.center, ), diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 9a7e78ddb8..0ef27f854b 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -221,8 +221,37 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) { handleSwipeUpDown(details); }, - onTapDown: (_, __, ___) { - ref.read(showControlsProvider.notifier).toggle(); + onTapDown: (ctx, tapDownDetails, _) { + final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.tapToNavigate); + if (!tapToNavigate) { + ref.read(showControlsProvider.notifier).toggle(); + return; + } + + double tapX = tapDownDetails.globalPosition.dx; + double screenWidth = ctx.width; + + // We want to change images if the user taps in the leftmost or + // rightmost quarter of the screen + bool tappedLeftSide = tapX < screenWidth / 4; + bool tappedRightSide = tapX > screenWidth * (3 / 4); + + int? currentPage = controller.page?.toInt(); + int maxPage = renderList.totalAssets - 1; + + if (tappedLeftSide && currentPage != null) { + // Nested if because we don't want to fallback to show/hide controls + if (currentPage != 0) { + controller.jumpToPage(currentPage - 1); + } + } else if (tappedRightSide && currentPage != null) { + // Nested if because we don't want to fallback to show/hide controls + if (currentPage != maxPage) { + controller.jumpToPage(currentPage + 1); + } + } else { + ref.read(showControlsProvider.notifier).toggle(); + } }, onLongPressStart: asset.isMotionPhoto ? (_, __, ___) { diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index 1cfab355d6..c7c34b9cd2 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -7,7 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; class SettingsHeader { String key = ""; @@ -61,7 +61,7 @@ class HeaderSettingsPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text(IntlKeys.headers_settings_tile_title).tr(), + title: Text(context.t.headers_settings_tile_title), centerTitle: false, actions: [ IconButton( diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index c9ab014456..2889785d0b 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -1,6 +1,4 @@ -import 'dart:async'; import 'dart:typed_data'; -import 'dart:ui'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -12,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:path/path.dart' as p; @@ -30,27 +29,10 @@ class EditImagePage extends ConsumerWidget { final bool isEdited; const EditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; - } Future _saveEditedImage(BuildContext context, Asset asset, Image image, WidgetRef ref) async { try { - final Uint8List imageData = await _imageToUint8List(image); + final Uint8List imageData = await imageToUint8List(image); await ref .read(fileMediaRepositoryProvider) .saveImage(imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg"); diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 6332a662b9..99a534e9cf 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; @@ -41,13 +41,13 @@ class LibraryPage extends ConsumerWidget { ActionButton( onPressed: () => context.pushRoute(const FavoritesRoute()), icon: Icons.favorite_outline_rounded, - label: IntlKeys.favorites.tr(), + label: context.t.favorites, ), const SizedBox(width: 8), ActionButton( onPressed: () => context.pushRoute(const ArchiveRoute()), icon: Icons.archive_outlined, - label: IntlKeys.archived.tr(), + label: context.t.archived, ), ], ), @@ -58,14 +58,14 @@ class LibraryPage extends ConsumerWidget { ActionButton( onPressed: () => context.pushRoute(const SharedLinkRoute()), icon: Icons.link_outlined, - label: IntlKeys.shared_links.tr(), + label: context.t.shared_links, ), SizedBox(width: trashEnabled ? 8 : 0), trashEnabled ? ActionButton( onPressed: () => context.pushRoute(const TrashRoute()), icon: Icons.delete_outline_rounded, - label: IntlKeys.trash.tr(), + label: context.t.trash, ) : const SizedBox.shrink(), ], @@ -120,26 +120,20 @@ class QuickAccessButtons extends ConsumerWidget { ), ), leading: const Icon(Icons.folder_outlined, size: 26), - title: Text( - IntlKeys.folders.tr(), - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), - ), + title: Text(context.t.folders, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)), onTap: () => context.pushRoute(FolderRoute()), ), ListTile( leading: const Icon(Icons.lock_outline_rounded, size: 26), title: Text( - IntlKeys.locked_folder.tr(), + context.t.locked_folder, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), ), onTap: () => context.pushRoute(const LockedRoute()), ), ListTile( leading: const Icon(Icons.group_outlined, size: 26), - title: Text( - IntlKeys.partners.tr(), - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), - ), + title: Text(context.t.partners, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)), onTap: () => context.pushRoute(const PartnerRoute()), ), PartnerList(partners: partners), @@ -230,7 +224,7 @@ class PeopleCollectionCard extends ConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - IntlKeys.people.tr(), + context.t.people, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -290,7 +284,7 @@ class LocalAlbumsCollectionCard extends HookConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - IntlKeys.on_this_device.tr(), + context.t.on_this_device, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -341,7 +335,7 @@ class PlacesCollectionCard extends StatelessWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - IntlKeys.places.tr(), + context.t.places, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 1d7eaef080..47a3dd853d 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -29,6 +29,8 @@ class SharedLinkEditPage extends HookConsumerWidget { final descriptionController = useTextEditingController(text: existingLink?.description ?? ""); final descriptionFocusNode = useFocusNode(); final passwordController = useTextEditingController(text: existingLink?.password ?? ""); + final slugController = useTextEditingController(text: existingLink?.slug ?? ""); + final slugFocusNode = useFocusNode(); final showMetadata = useState(existingLink?.showMetadata ?? true); final allowDownload = useState(existingLink?.allowDownload ?? true); final allowUpload = useState(existingLink?.allowUpload ?? false); @@ -108,6 +110,26 @@ class SharedLinkEditPage extends HookConsumerWidget { ); } + Widget buildSlugField() { + return TextField( + controller: slugController, + enabled: newShareLink.value.isEmpty, + focusNode: slugFocusNode, + textInputAction: TextInputAction.done, + autofocus: false, + decoration: InputDecoration( + labelText: 'custom_url'.tr(), + labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary), + floatingLabelBehavior: FloatingLabelBehavior.always, + border: const OutlineInputBorder(), + hintText: 'custom_url'.tr(), + hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), + disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))), + ), + onTapOutside: (_) => slugFocusNode.unfocus(), + ); + } + Widget buildShowMetaButton() { return SwitchListTile.adaptive( value: showMetadata.value, @@ -261,6 +283,7 @@ class SharedLinkEditPage extends HookConsumerWidget { allowUpload: allowUpload.value, description: descriptionController.text.isEmpty ? null : descriptionController.text, password: passwordController.text.isEmpty ? null : passwordController.text, + slug: slugController.text.isEmpty ? null : slugController.text, expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(), ); ref.invalidate(sharedLinksStateProvider); @@ -274,7 +297,10 @@ class SharedLinkEditPage extends HookConsumerWidget { } if (newLink != null && serverUrl != null) { - newShareLink.value = "${serverUrl}share/${newLink.key}"; + final hasSlug = newLink.slug?.isNotEmpty == true; + final urlPath = hasSlug ? newLink.slug : newLink.key; + final basePath = hasSlug ? 's' : 'share'; + newShareLink.value = "$serverUrl$basePath/$urlPath"; copyLinkToClipboard(); } else if (newLink == null) { ImmichToast.show( @@ -292,6 +318,7 @@ class SharedLinkEditPage extends HookConsumerWidget { bool? meta; String? desc; String? password; + String? slug; DateTime? expiry; bool? changeExpiry; @@ -315,6 +342,12 @@ class SharedLinkEditPage extends HookConsumerWidget { password = passwordController.text; } + if (slugController.text != (existingLink!.slug ?? "")) { + slug = slugController.text.isEmpty ? null : slugController.text; + } else { + slug = existingLink!.slug; + } + if (editExpiry.value) { expiry = expiryAfter.value == 0 ? null : calculateExpiry(); changeExpiry = true; @@ -329,6 +362,7 @@ class SharedLinkEditPage extends HookConsumerWidget { allowUpload: upload, description: desc, password: password, + slug: slug, expiresAt: expiry, changeExpiry: changeExpiry, ); @@ -349,6 +383,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()), Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()), Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()), + Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()), Padding( padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding), child: buildShowMetaButton(), diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index e366cf70f1..993b91d8f7 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -118,7 +118,7 @@ class MapPage extends HookConsumerWidget { } // finds the nearest asset marker from the tap point and store it as the selectedMarker - Future onMarkerClicked(Point point, LatLng coords) async { + Future onMarkerClicked(Point point, LatLng _) async { // Guard map not created if (mapController.value == null) { return; 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 a2c927c6bd..3dace15ced 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -28,7 +28,7 @@ class MapLocationPickerPage extends HookConsumerWidget { marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng); } - Future onMapClick(Point point, LatLng centre) async { + Future onMapClick(Point _, LatLng centre) async { selectedLatLng.value = centre; await controller.value?.animateCamera(CameraUpdate.newLatLng(centre)); if (marker.value != null) { diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart deleted file mode 100644 index 37c412a0e9..0000000000 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_ui/immich_ui.dart'; - -List _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) { - final children = []; - - final items = [ - (variant: ImmichVariant.filled, title: "Filled Variant"), - (variant: ImmichVariant.ghost, title: "Ghost Variant"), - ]; - - for (final (:variant, :title) in items) { - children.add(Text(title)); - children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)])); - } - - return children; -} - -class _ComponentTitle extends StatelessWidget { - final String title; - - const _ComponentTitle(this.title); - - @override - Widget build(BuildContext context) { - return Text(title, style: context.textTheme.titleLarge); - } -} - -@RoutePage() -class ImmichUIShowcasePage extends StatelessWidget { - const ImmichUIShowcasePage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Immich UI Showcase')), - body: Padding( - padding: const EdgeInsets.all(20), - child: SingleChildScrollView( - child: Column( - spacing: 10, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _ComponentTitle("IconButton"), - ..._showcaseBuilder( - (variant, color) => - ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}), - ), - const _ComponentTitle("CloseButton"), - ..._showcaseBuilder( - (variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}), - ), - const _ComponentTitle("TextButton"), - - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.filled, - color: ImmichColor.primary, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.filled, - color: ImmichColor.primary, - loading: true, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.ghost, - color: ImmichColor.primary, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.ghost, - color: ImmichColor.primary, - loading: true, - ), - const _ComponentTitle("Form"), - ImmichForm( - onSubmit: () {}, - child: const Column( - spacing: 10, - children: [ImmichTextInput(label: "Title", hintText: "Enter a title")], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index ac0cd7f309..fa5737443f 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { final RemoteAlbum album; + final String? assetId; + final String? assetName; - const DriftActivitiesPage({super.key, required this.album}); + const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName}); @override Widget build(BuildContext context, WidgetRef ref) { - final activityNotifier = ref.read(albumActivityProvider(album.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id)); + final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier); + final activities = ref.watch(albumActivityProvider(album.id, assetId)); final listViewScrollController = useScrollController(); void scrollToBottom() { @@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget { overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], child: Scaffold( appBar: AppBar( - title: Text(album.name), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(album.name), + if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall), + ], + ), actions: [const LikeActivityActionButton(iconOnly: true)], actionsPadding: const EdgeInsets.only(right: 8), ), @@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget { activityWidgets.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: CommentBubble(activity: activity), + child: CommentBubble(activity: activity, isAssetActivity: assetId != null), ), ); } diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index cde8c127db..c9fed636b4 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState { pinned: true, actions: [ IconButton( - icon: const Icon(Icons.add_rounded, size: 28), onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()), + icon: const Icon(Icons.add_rounded), ), ], showUploadButton: false, diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 9db6e98613..061edbaf26 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -149,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { } return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context), @@ -169,7 +169,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { itemBuilder: (context, index) { final user = sharedUsers[index]; return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 9042f2f1f5..147165f2a3 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.wid import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; diff --git a/mobile/lib/presentation/pages/drift_trash.page.dart b/mobile/lib/presentation/pages/drift_trash.page.dart index 8713166027..a85f69a75e 100644 --- a/mobile/lib/presentation/pages/drift_trash.page.dart +++ b/mobile/lib/presentation/pages/drift_trash.page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -43,9 +44,7 @@ class DriftTrashPage extends StatelessWidget { return SliverPadding( padding: const EdgeInsets.all(16.0), - sliver: SliverToBoxAdapter( - child: const Text("trash_page_info").t(context: context, args: {"days": "$trashDays"}), - ), + sliver: SliverToBoxAdapter(child: Text(context.t.trash_page_info(days: trashDays))), ); }, ), diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index 7e49348e19..a10202973d 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui'; import 'package:auto_route/auto_route.dart'; import 'package:cancellation_token_http/http.dart'; @@ -14,6 +13,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -33,23 +33,6 @@ class DriftEditImagePage extends ConsumerWidget { final bool isEdited; const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; - } void _exitEditing(BuildContext context) { // this assumes that the only way to get to this page is from the AssetViewerRoute @@ -58,7 +41,7 @@ class DriftEditImagePage extends ConsumerWidget { Future _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async { try { - final Uint8List imageData = await _imageToUint8List(image); + final Uint8List imageData = await imageToUint8List(image); LocalAsset? localAsset; try { diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart new file mode 100644 index 0000000000..f460633cbb --- /dev/null +++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:crop_image/crop_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_ui/immich_ui.dart'; + +@RoutePage() +class ProfilePictureCropPage extends ConsumerStatefulWidget { + final BaseAsset asset; + + const ProfilePictureCropPage({super.key, required this.asset}); + + @override + ConsumerState createState() => _ProfilePictureCropPageState(); +} + +class _ProfilePictureCropPageState extends ConsumerState { + late final CropController _cropController; + bool _isLoading = false; + bool _didInitCropController = false; + + @override + void initState() { + super.initState(); + _cropController = CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1)); + + // Lock aspect ratio to 1:1 for circular/square crop + // CropController depends on CropImage initializing its bitmap size. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _didInitCropController) { + return; + } + _didInitCropController = true; + + _cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + _cropController.aspectRatio = 1.0; + }); + } + + @override + void dispose() { + _cropController.dispose(); + super.dispose(); + } + + Future _handleDone() async { + if (_isLoading) return; + + setState(() { + _isLoading = true; + }); + + try { + final croppedImage = await _cropController.croppedImage(); + final pngBytes = await imageToUint8List(croppedImage); + final xFile = XFile.fromData(pngBytes, mimeType: 'image/png'); + final success = await ref + .read(uploadProfileImageProvider.notifier) + .upload(xFile, fileName: 'profile-picture.png'); + + if (!context.mounted) return; + + if (success) { + final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; + ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath); + final user = ref.read(currentUserProvider); + if (user != null) { + unawaited(ref.read(currentUserProvider.notifier).refresh()); + } + unawaited(ref.read(backupProvider.notifier).updateDiskInfo()); + + ImmichToast.show( + context: context, + msg: 'profile_picture_set'.tr(), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + + if (context.mounted) { + unawaited(context.maybePop()); + } + } else { + ImmichToast.show( + context: context, + msg: 'errors.unable_to_set_profile_picture'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + } catch (e) { + if (!context.mounted) return; + + ImmichToast.show( + context: context, + msg: 'errors.unable_to_set_profile_picture'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + // Create Image widget from asset + final image = Image(image: getFullImageProvider(widget.asset)); + + return Scaffold( + appBar: AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("set_profile_picture".tr()), + leading: _isLoading ? null : const ImmichCloseButton(), + actions: [ + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + ) + else + ImmichIconButton( + icon: Icons.done_rounded, + color: ImmichColor.primary, + variant: ImmichVariant.ghost, + onPressed: _handleDone, + ), + ], + ), + backgroundColor: context.scaffoldBackgroundColor, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(7)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + child: CropImage(controller: _cropController, image: image, gridColor: Colors.white), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 16655e98f6..0ce3f20641 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -20,9 +21,11 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/feature_check.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:immich_mobile/widgets/common/tag_picker.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'; @@ -39,8 +42,15 @@ class DriftSearchPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final textSearchType = useState(TextSearchType.context); - final searchHintText = useState('sunrise_on_the_beach'.t(context: context)); + final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures)); + final textSearchType = useState( + serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename, + ); + final searchHintText = useState( + serverFeatures.smartSearch + ? 'sunrise_on_the_beach'.t(context: context) + : 'file_name_or_extension'.t(context: context), + ); final textSearchController = useTextEditingController(); final preFilter = ref.watch(searchPreFilterProvider); final filter = useState( @@ -54,6 +64,7 @@ class DriftSearchPage extends HookConsumerWidget { mediaType: preFilter?.mediaType ?? AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", assetId: preFilter?.assetId, + tagIds: preFilter?.tagIds ?? [], ), ); @@ -64,15 +75,14 @@ class DriftSearchPage extends HookConsumerWidget { final dateRangeCurrentFilterWidget = useState(null); final cameraCurrentFilterWidget = useState(null); final locationCurrentFilterWidget = useState(null); + final tagCurrentFilterWidget = useState(null); final mediaTypeCurrentFilterWidget = useState(null); final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); final isSearching = useState(false); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + final userPreferences = ref.watch(userMetadataPreferencesProvider); SnackBar searchInfoSnackBar(String message) { return SnackBar( @@ -140,10 +150,12 @@ class DriftSearchPage extends HookConsumerWidget { handleOnSelect(Set value) { filter.value = filter.value.copyWith(people: value); - peopleCurrentFilterWidget.value = Text( - value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '), - style: context.textTheme.labelLarge, - ); + final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '); + if (label.isNotEmpty) { + peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge); + } else { + peopleCurrentFilterWidget.value = null; + } } handleClear() { @@ -169,6 +181,42 @@ class DriftSearchPage extends HookConsumerWidget { ); } + showTagPicker() { + handleOnSelect(Iterable tags) { + filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList()); + final label = tags.map((t) => t.value).join(', '); + if (label.isEmpty) { + tagCurrentFilterWidget.value = null; + } else { + tagCurrentFilterWidget.value = Text( + label.isEmpty ? 'tags'.t(context: context) : label, + style: context.textTheme.labelLarge, + ); + } + } + + handleClear() { + filter.value = filter.value.copyWith(tagIds: []); + tagCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_tags_title'.t(context: context), + expanded: true, + onSearch: search, + onClear: handleClear, + child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + ), + ), + ); + } + showLocationPicker() { handleOnSelect(Map value) { filter.value = filter.value.copyWith( @@ -518,23 +566,26 @@ class DriftSearchPage extends HookConsumerWidget { ); }, menuChildren: [ - MenuItemButton( - child: ListTile( - leading: const Icon(Icons.image_search_rounded), - title: Text( - 'search_by_context'.t(context: context), - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null, + FeatureCheck( + feature: (features) => features.smartSearch, + child: MenuItemButton( + child: ListTile( + leading: const Icon(Icons.image_search_rounded), + title: Text( + 'search_by_context'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null, + ), ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.context, ), - selectedColor: context.colorScheme.primary, - selected: textSearchType.value == TextSearchType.context, + onPressed: () { + textSearchType.value = TextSearchType.context; + searchHintText.value = 'sunrise_on_the_beach'.t(context: context); + }, ), - onPressed: () { - textSearchType.value = TextSearchType.context; - searchHintText.value = 'sunrise_on_the_beach'.t(context: context); - }, ), MenuItemButton( child: ListTile( @@ -647,6 +698,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, ), + if (userPreferences.valueOrNull?.tagsEnabled ?? false) + SearchFilterChip( + icon: Icons.sell_outlined, + onTap: showTagPicker, + label: 'tags'.t(context: context), + currentFilter: tagCurrentFilterWidget.value, + ), SearchFilterChip( icon: Icons.camera_alt_outlined, onTap: showCameraPicker, @@ -666,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), - if (isRatingEnabled) ...[ + if (userPreferences.valueOrNull?.ratingsEnabled ?? false) SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, label: 'search_filter_star_rating'.t(context: context), currentFilter: ratingCurrentFilterWidget.value, ), - ], SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 23cd19f363..4162f43a24 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 4c7b6ffbdc..440985a0bb 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 8c326974a7..a44b0b5815 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart b/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart new file mode 100644 index 0000000000..1d704aafe8 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class SetAlbumCoverActionButton extends ConsumerWidget { + final String albumId; + final ActionSource source; + final bool iconOnly; + final bool menuItem; + + const SetAlbumCoverActionButton({ + super.key, + required this.albumId, + required this.source, + this.iconOnly = false, + this.menuItem = false, + }); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).setAlbumCover(source, albumId); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'album_cover_updated'.t(context: context); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.image_outlined, + label: 'set_as_album_cover'.t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart new file mode 100644 index 0000000000..c8dbb7cb1f --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart @@ -0,0 +1,35 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SetProfilePictureActionButton extends ConsumerWidget { + final BaseAsset asset; + final bool iconOnly; + final bool menuItem; + + const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context) { + if (!context.mounted) { + return; + } + + context.pushRoute(ProfilePictureCropRoute(asset: asset)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.account_circle_outlined, + label: "set_as_profile_picture".t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 8f3cee9215..15749fb9af 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart index fe5c763ec5..691b46f80d 100644 --- a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart +++ b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart @@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState prefixIcon: user != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 15), - child: UserCircleAvatar(user: user, size: 30, radius: 15), + child: UserCircleAvatar(user: user, size: 30), ) : null, suffixIcon: IconButton( diff --git a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart deleted file mode 100644 index 3b46b69958..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/activities/comment_bubble.dart'; -import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart'; -import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; - -class ActivitiesBottomSheet extends HookConsumerWidget { - final DraggableScrollableController controller; - final double initialChildSize; - final bool scrollToBottomInitially; - - const ActivitiesBottomSheet({ - required this.controller, - this.initialChildSize = 0.35, - this.scrollToBottomInitially = true, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; - - final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); - - Future onAddComment(String comment) async { - await activityNotifier.addComment(comment); - } - - Widget buildActivitiesSliver() { - return activities.widgetWhen( - onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()), - onData: (data) { - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (index == data.length) { - return const SizedBox.shrink(); - } - final activity = data[data.length - 1 - index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: CommentBubble(activity: activity, isAssetActivity: true), - ); - }, childCount: data.length + 1), - ); - }, - ); - } - - return BaseBottomSheet( - actions: [], - slivers: [buildActivitiesSliver()], - footer: Padding( - // TODO: avoid fixed padding, use context.padding.bottom - padding: const EdgeInsets.only(bottom: 32), - child: Column( - children: [ - const Divider(indent: 16, endIndent: 16), - DriftActivityTextField( - isEnabled: album.isActivityEnabled, - isBottomSheet: true, - // likeId: likedId, - onSubmit: onAddComment, - ), - ], - ), - ), - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart new file mode 100644 index 0000000000..949a6917e9 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; + +class AssetDetails extends ConsumerWidget { + final double minHeight; + + const AssetDetails({required this.minHeight, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DragHandle(), + const DateTimeDetails(), + const PeopleDetails(), + const LocationDetails(), + const TechnicalDetails(), + const RatingDetails(), + const AppearsInDetails(), + SizedBox(height: context.padding.bottom + 48), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart new file mode 100644 index 0000000000..a3d6bdb8ab --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +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/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class AppearsInDetails extends ConsumerWidget { + const AppearsInDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null || !asset.hasRemote) return const SizedBox.shrink(); + + String? remoteAssetId; + if (asset is RemoteAsset) { + remoteAssetId = asset.id; + } else if (asset is LocalAsset) { + remoteAssetId = asset.remoteAssetId; + } + + if (remoteAssetId == null) return const SizedBox.shrink(); + + final userId = ref.watch(currentUserProvider)?.id; + final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); + + return assetAlbums.when( + data: (albums) { + if (albums.isEmpty) return const SizedBox.shrink(); + + albums.sortBy((a) => a.name); + + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + spacing: 12, + children: [ + SheetTile( + title: 'appears_in'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + Padding( + padding: const EdgeInsets.only(left: 12), + child: Column( + spacing: 12, + children: albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + ref.invalidate(assetViewerProvider); + unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); + }, + ); + }).toList(), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart new file mode 100644 index 0000000000..4872bf9e75 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +const _kSeparator = ' â€ĸ '; + +class DateTimeDetails extends ConsumerWidget { + const DateTimeDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + + return Column( + children: [ + SheetTile( + title: _getDateTime(context, asset, exifInfo), + titleStyle: context.textTheme.labelLarge, + trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, + onTap: asset.hasRemote && isOwner + ? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context) + : null, + ), + if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), + ], + ); + } + + static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); + final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; + return '$date$_kSeparator$time $timezone'; + } +} + +class _SheetAssetDescription extends ConsumerStatefulWidget { + final ExifInfo exif; + final bool isEditable; + + const _SheetAssetDescription({required this.exif, this.isEditable = true}); + + @override + ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); +} + +class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { + late TextEditingController _controller; + final _descriptionFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.exif.description ?? ''); + } + + Future saveDescription(String? previousDescription) async { + final newDescription = _controller.text.trim(); + + if (newDescription == previousDescription) { + _descriptionFocus.unfocus(); + return; + } + + final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); + + if (!editAction.success) { + _controller.text = previousDescription ?? ''; + + ImmichToast.show( + context: context, + msg: 'exif_bottom_sheet_description_error'.t(context: context), + toastType: ToastType.error, + ); + } + + _descriptionFocus.unfocus(); + } + + @override + Widget build(BuildContext context) { + final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + final currentDescription = currentExifInfo?.description ?? ''; + final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( + context: context, + ); + if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { + _controller.text = currentDescription; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: IgnorePointer( + ignoring: !widget.isEditable, + child: TextField( + controller: _controller, + keyboardType: TextInputType.multiline, + maxLines: null, + focusNode: _descriptionFocus, + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + ), + onTapOutside: (_) => saveDescription(currentExifInfo?.description), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart new file mode 100644 index 0000000000..8c24c5004c --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class DragHandle extends StatelessWidget { + const DragHandle({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(2)), + color: context.colorScheme.onSurfaceVariant, + ), + ), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index ce561c4016..0665f4d46c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class SheetLocationDetails extends ConsumerStatefulWidget { - const SheetLocationDetails({super.key}); +class LocationDetails extends ConsumerStatefulWidget { + const LocationDetails({super.key}); @override - ConsumerState createState() => _SheetLocationDetailsState(); + ConsumerState createState() => _LocationDetailsState(); } -class _SheetLocationDetailsState extends ConsumerState { +class _LocationDetailsState extends ConsumerState { MapLibreMapController? _mapController; String? _getLocationName(ExifInfo? exifInfo) { @@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState { void _onExifChanged(AsyncValue? previous, AsyncValue current) { final currentExif = current.valueOrNull; - if (currentExif != null && currentExif.hasCoordinates) { _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 7eb9e578ff..5074c63c9c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; @@ -15,14 +15,14 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; -class SheetPeopleDetails extends ConsumerStatefulWidget { - const SheetPeopleDetails({super.key}); +class PeopleDetails extends ConsumerStatefulWidget { + const PeopleDetails({super.key}); @override - ConsumerState createState() => _SheetPeopleDetailsState(); + ConsumerState createState() => _PeopleDetailsState(); } -class _SheetPeopleDetailsState extends ConsumerState { +class _PeopleDetailsState extends ConsumerState { @override Widget build(BuildContext context) { final asset = ref.watch(currentAssetNotifier); @@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState { scrollDirection: Axis.horizontal, children: [ for (final person in people) - _PeopleAvatar( + _Avatar( person: person, assetFileCreatedAt: asset.createdAt, onTap: () { @@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState { } } -class _PeopleAvatar extends StatelessWidget { +class _Avatar extends StatelessWidget { final DriftPerson person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; final double imageSize = 96; - const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); + const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); @override Widget build(BuildContext context) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart new file mode 100644 index 0000000000..982ea67583 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; + +class RatingDetails extends ConsumerWidget { + const RatingDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + + if (!isRatingEnabled) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + return Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'rating'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + RatingBar( + initialRating: exifInfo?.rating?.toDouble() ?? 0, + filledColor: context.themeData.colorScheme.primary, + unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), + itemSize: 40, + onRatingUpdate: (rating) async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); + }, + onClearRating: () async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart new file mode 100644 index 0000000000..d79362b559 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +const _kSeparator = ' â€ĸ '; + +class TechnicalDetails extends ConsumerWidget { + const TechnicalDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final cameraTitle = _getCameraInfoTitle(exifInfo); + final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; + + return Column( + children: [ + SheetTile( + title: 'details'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + _buildFileInfoTile(context, ref, asset, exifInfo), + if (cameraTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: cameraTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getCameraInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + if (lensTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: lensTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getLensInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + ], + ); + } + + Widget _buildFileInfoTile(BuildContext context, WidgetRef ref, BaseAsset asset, ExifInfo? exifInfo) { + final icon = Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ); + final subtitle = _getFileInfo(asset, exifInfo); + final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary); + + if (asset is LocalAsset) { + final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + return FutureBuilder( + future: assetMediaRepository.getOriginalFilename(asset.id), + builder: (context, snapshot) { + return SheetTile( + title: snapshot.data ?? asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + }, + ); + } + + return SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + } + + static String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { + final height = asset.height; + final width = asset.width; + final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; + final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; + + return switch ((fileSize, resolution)) { + (null, null) => '', + (String fileSize, null) => fileSize, + (null, String resolution) => resolution, + (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', + }; + } + + static String? _getCameraInfoTitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + return switch ((exifInfo.make, exifInfo.model)) { + (null, null) => null, + (String make, null) => make, + (null, String model) => model, + (String make, String model) => '$make $model', + }; + } + + static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; + final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } + + static String? _getLensInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart new file mode 100644 index 0000000000..125ad36f9a --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -0,0 +1,465 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/gestures.dart' show Drag, kTouchSlop; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +enum _DragIntent { none, scroll, dismiss } + +class AssetPage extends ConsumerStatefulWidget { + final int index; + final int heroOffset; + final void Function(int direction)? onTapNavigate; + + const AssetPage({super.key, required this.index, required this.heroOffset, this.onTapNavigate}); + + @override + ConsumerState createState() => _AssetPageState(); +} + +class _AssetPageState extends ConsumerState { + PhotoViewControllerBase? _viewController; + StreamSubscription? _scaleBoundarySub; + StreamSubscription? _eventSubscription; + + AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier); + + late PhotoViewControllerValue _initialPhotoViewState; + + bool _showingDetails = false; + bool _isZoomed = false; + + final _scrollController = ScrollController(); + late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); + final ValueNotifier _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial); + + double _snapOffset = 0.0; + double _lastScrollOffset = 0.0; + + DragStartDetails? _dragStart; + _DragIntent _dragIntent = _DragIntent.none; + Drag? _drag; + + @override + void initState() { + super.initState(); + _proxyScrollController.addListener(_onScroll); + _eventSubscription = EventStream.shared.listen(_onEvent); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_proxyScrollController.hasClients) return; + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (_showingDetails && _snapOffset > 0) { + _proxyScrollController.jumpTo(_snapOffset); + } + }); + } + + @override + void dispose() { + _proxyScrollController.dispose(); + _scaleBoundarySub?.cancel(); + _eventSubscription?.cancel(); + _videoScaleStateNotifier.dispose(); + super.dispose(); + } + + void _onEvent(Event event) { + switch (event) { + case ViewerShowDetailsEvent(): + _showDetails(); + default: + } + } + + void _showDetails() { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; + _lastScrollOffset = _proxyScrollController.offset; + _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); + } + + bool _willClose(double scrollVelocity) { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false; + + final position = _proxyScrollController.position; + return _proxyScrollController.position.pixels < _snapOffset && + SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance; + } + + void _onScroll() { + final offset = _proxyScrollController.offset; + if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) { + _viewer.setShowingDetails(true); + } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { + _viewer.setShowingDetails(false); + } + _lastScrollOffset = offset; + } + + void _beginDrag(DragStartDetails details) { + _dragStart = details; + _lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0; + + if (_viewController != null) { + _initialPhotoViewState = _viewController!.value; + } + + if (_showingDetails) { + _dragIntent = _DragIntent.scroll; + _startProxyDrag(); + } + } + + void _startProxyDrag() { + if (_proxyScrollController.hasClients && _dragStart != null) { + _drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null); + } + } + + void _updateDrag(DragUpdateDetails details) { + if (_dragStart == null) return; + + if (_dragIntent == _DragIntent.none) { + _dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) { + < 0 => _DragIntent.scroll, + > 0 => _DragIntent.dismiss, + _ => _DragIntent.none, + }; + } + + switch (_dragIntent) { + case _DragIntent.none: + case _DragIntent.scroll: + if (_drag == null) _startProxyDrag(); + _drag?.update(details); + case _DragIntent.dismiss: + _handleDragDown(context, details.localPosition - _dragStart!.localPosition); + } + } + + void _endDrag(DragEndDetails details) { + if (_dragStart == null) return; + + final start = _dragStart; + _dragStart = null; + + final intent = _dragIntent; + _dragIntent = _DragIntent.none; + + switch (intent) { + case _DragIntent.none: + case _DragIntent.scroll: + final scrollVelocity = -(details.primaryVelocity ?? 0.0); + if (_willClose(scrollVelocity)) { + _viewer.setShowingDetails(false); + } + _drag?.end(details); + _drag = null; + case _DragIntent.dismiss: + const popThreshold = 75.0; + if (details.localPosition.dy - start!.localPosition.dy > popThreshold) { + context.maybePop(); + return; + } + _viewController?.animateMultiple( + position: _initialPhotoViewState.position, + scale: _viewController?.initialScale ?? _initialPhotoViewState.scale, + rotation: _initialPhotoViewState.rotation, + ); + _viewer.setOpacity(1.0); + } + } + + void _onDragStart( + BuildContext context, + DragStartDetails details, + PhotoViewControllerBase controller, + PhotoViewScaleStateController scaleStateController, + ) { + if (!_showingDetails && _isZoomed) return; + _beginDrag(details); + } + + void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) => + _updateDrag(details); + + void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details); + + void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0)); + + void _handleDragDown(BuildContext context, Offset delta) { + const dragRatio = 0.2; + + final distance = delta.dy.abs(); + final maxScaleDistance = context.height * 0.5; + final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); + final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale; + final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null; + + final opacity = 1.0 - (scaleReduction / dragRatio); + + _viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale); + _viewer.setOpacity(opacity); + } + + void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) { + if (_showingDetails || _dragStart != null) return; + + final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.tapToNavigate); + if (!tapToNavigate) { + _viewer.toggleControls(); + return; + } + + final tapX = details.globalPosition.dx; + final screenWidth = context.width; + + // Navigate if the user taps in the leftmost or rightmost quarter of the screen + final tappedLeftSide = tapX < screenWidth / 4; + final tappedRightSide = tapX > screenWidth * (3 / 4); + + if (tappedLeftSide) { + widget.onTapNavigate?.call(-1); + } else if (tappedRightSide) { + widget.onTapNavigate?.call(1); + } else { + _viewer.toggleControls(); + } + } + + void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) => + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; + + void _onScaleStateChanged(PhotoViewScaleState scaleState) { + _isZoomed = + scaleState == PhotoViewScaleState.zoomedIn || + scaleState == PhotoViewScaleState.covering || + _videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn || + _videoScaleStateNotifier.value == PhotoViewScaleState.covering; + _viewer.setZoomed(_isZoomed); + + if (scaleState != PhotoViewScaleState.initial) { + if (_dragStart == null) _viewer.setControls(false); + + ref.read(videoPlayerControlsProvider.notifier).pause(); + return; + } + + if (!_showingDetails) _viewer.setControls(true); + } + + void _listenForScaleBoundaries(PhotoViewControllerBase? controller) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (controller == null || controller.scaleBoundaries != null) return; + _scaleBoundarySub = controller.outputStateStream.listen((_) { + if (controller.scaleBoundaries != null) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (mounted) setState(() {}); + } + }); + } + + double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) { + final sb = _viewController?.scaleBoundaries; + if (sb != null) return sb.childSize.height * sb.initialScale; + + if (asset == null || asset.width == null || asset.height == null) return maxHeight; + + final r = asset.width! / asset.height!; + return math.min(maxWidth / r, maxHeight); + } + + void _onPageBuild(PhotoViewControllerBase controller) { + _viewController = controller; + _listenForScaleBoundaries(controller); + } + + Widget _buildPhotoView( + BaseAsset displayAsset, + BaseAsset asset, { + required bool isCurrentPage, + required bool showingDetails, + required bool isPlayingMotionVideo, + required BoxDecoration backgroundDecoration, + }) { + final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null; + + if (displayAsset.isImage && !isPlayingMotionVideo) { + final size = context.sizeData; + return PhotoView( + key: ValueKey(displayAsset.heroTag), + index: widget.index, + imageProvider: getFullImageProvider(displayAsset, size: size), + heroAttributes: heroAttributes, + loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), + backgroundDecoration: backgroundDecoration, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + disableScaleGestures: showingDetails, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, + errorBuilder: (_, __, ___) => SizedBox( + width: size.width, + height: size.height, + child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), + ), + ); + } + + return PhotoView.customChild( + key: ValueKey(displayAsset), + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + heroAttributes: heroAttributes, + filterQuality: FilterQuality.high, + basePosition: Alignment.center, + disableScaleGestures: true, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + tightMode: true, + onPageBuild: _onPageBuild, + enablePanAlways: true, + backgroundDecoration: backgroundDecoration, + child: NativeVideoViewer( + key: ValueKey(displayAsset), + asset: displayAsset, + scaleStateNotifier: _videoScaleStateNotifier, + disableScaleGestures: showingDetails, + image: Image( + key: ValueKey(displayAsset.heroTag), + image: getFullImageProvider(displayAsset, size: context.sizeData), + height: context.height, + width: context.width, + fit: BoxFit.contain, + alignment: Alignment.center, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); + final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); + + final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index); + if (asset == null) { + return const Center(child: ImmichLoadingIndicator()); + } + + BaseAsset displayAsset = asset; + final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + displayAsset = stackChildren.elementAt(stackIndex); + } + + final viewportWidth = MediaQuery.widthOf(context); + final viewportHeight = MediaQuery.heightOf(context); + final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); + + final detailsOffset = (viewportHeight + imageHeight - kMinInteractiveDimension) / 2; + final snapTarget = viewportHeight / 3; + + _snapOffset = detailsOffset - snapTarget; + + if (_proxyScrollController.hasClients) { + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + } + + return ProviderScope( + overrides: [ + currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)), + currentAssetExifProvider.overrideWith((ref) { + final a = ref.watch(currentAssetNotifier); + if (a == null) return Future.value(null); + return ref.watch(assetServiceProvider).getExif(a); + }), + ], + child: Stack( + children: [ + Offstage( + child: SingleChildScrollView( + controller: _proxyScrollController, + physics: const SnapScrollPhysics(), + child: const SizedBox.shrink(), + ), + ), + SingleChildScrollView( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + SizedBox( + width: viewportWidth, + height: viewportHeight, + child: _buildPhotoView( + displayAsset, + asset, + isCurrentPage: currentHeroTag == asset.heroTag, + showingDetails: _showingDetails, + isPlayingMotionVideo: isPlayingMotionVideo, + backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), + ), + ), + IgnorePointer( + ignoring: !_showingDetails, + child: Column( + children: [ + SizedBox(height: detailsOffset), + GestureDetector( + onVerticalDragStart: _beginDrag, + onVerticalDragUpdate: _updateDrag, + onVerticalDragEnd: _endDrag, + onVerticalDragCancel: _onDragCancel, + child: AnimatedOpacity( + opacity: _showingDetails ? 1.0 : 0.0, + duration: Durations.short2, + child: AssetDetails(minHeight: viewportHeight - snapTarget), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart new file mode 100644 index 0000000000..ca7498a37f --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; + +class AssetPreloader { + static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); + + final TimelineService timelineService; + final bool Function() mounted; + + Timer? _timer; + ImageStream? _prevStream; + ImageStream? _nextStream; + + AssetPreloader({required this.timelineService, required this.mounted}); + + void preload(int index, Size size) { + unawaited(timelineService.preloadAssets(index)); + _timer?.cancel(); + _timer = Timer(Durations.medium4, () async { + if (!mounted()) return; + final (prev, next) = await ( + timelineService.getAssetAsync(index - 1), + timelineService.getAssetAsync(index + 1), + ).wait; + if (!mounted()) return; + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + _prevStream = prev != null ? _resolveImage(prev, size) : null; + _nextStream = next != null ? _resolveImage(next, size) : null; + }); + } + + ImageStream _resolveImage(BaseAsset asset, Size size) { + return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener); + } + + void dispose() { + _timer?.cancel(); + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 0978b3c9af..2835342b85 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetStackRow extends ConsumerWidget { const AssetStackRow({super.key}); @@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0; - - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: _StackList(stack: stackChildren), - ), - ); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { + return const SizedBox.shrink(); + } + return _StackList(stack: stackChildren); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index ed2ab9d15d..3ed5fb2034 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -14,27 +14,19 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; -import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; @RoutePage() class AssetViewerPage extends StatelessWidget { @@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget { _setAsset(ref, asset); } - void changeAsset(WidgetRef ref, BaseAsset asset) { - _setAsset(ref, asset); - } - static void _setAsset(WidgetRef ref, BaseAsset asset) { // Always holds the current asset from the timeline ref.read(assetViewerProvider.notifier).setAsset(asset); @@ -94,140 +82,67 @@ class AssetViewer extends ConsumerStatefulWidget { ref.read(videoPlayerControlsProvider.notifier).pause(); } // Hide controls by default for videos - if (asset.isVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } + if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); } } -const double _kBottomSheetMinimumExtent = 0.4; -const double _kBottomSheetSnapExtent = 0.67; - class _AssetViewerState extends ConsumerState { - static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); - late PageController pageController; - late DraggableScrollableController bottomSheetController; - PersistentBottomSheetController? sheetCloseController; - // PhotoViewGallery takes care of disposing it's controllers - PhotoViewControllerBase? viewController; - StreamSubscription? reloadSubscription; - - late final int heroOffset; - late PhotoViewControllerValue initialPhotoViewState; - bool? hasDraggedDown; - bool isSnapping = false; - bool blockGestures = false; - bool dragInProgress = false; - bool shouldPopOnDrag = false; - bool assetReloadRequested = false; - double previousExtent = _kBottomSheetMinimumExtent; - Offset dragDownPosition = Offset.zero; - int totalAssets = 0; - int stackIndex = 0; - BuildContext? scaffoldContext; - Map videoPlayerKeys = {}; - - // Delayed operations that should be cancelled on disposal - final List _delayedOperations = []; - - ImageStream? _prevPreCacheStream; - ImageStream? _nextPreCacheStream; + late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; + late final _pageController = PageController(initialPage: widget.initialIndex); + late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); + StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; + bool _assetReloadRequested = false; + + void _onTapNavigate(int direction) { + final page = _pageController.page?.toInt(); + if (page == null) return; + final target = page + direction; + final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; + if (target >= 0 && target <= maxPage) { + _pageController.jumpToPage(target); + } + } + @override void initState() { super.initState(); - assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); - pageController = PageController(initialPage: widget.initialIndex); - totalAssets = ref.read(timelineServiceProvider).totalAssets; - bottomSheetController = DraggableScrollableController(); - WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); - reloadSubscription = EventStream.shared.listen(_onEvent); - heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; + final asset = ref.read(currentAssetNotifier); - if (asset != null) { - _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); - } - if (ref.read(assetViewerProvider).showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + assert(asset != null, "Current asset should not be null when opening the AssetViewer"); + if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); + + _reloadSubscription = EventStream.shared.listen(_onEvent); + + WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); } @override void dispose() { - pageController.dispose(); - bottomSheetController.dispose(); - _cancelTimers(); - reloadSubscription?.cancel(); - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + _pageController.dispose(); + _preloader.dispose(); + _reloadSubscription?.cancel(); _stackChildrenKeepAlive?.close(); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); } - bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); - - Color get backgroundColor { - final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); - return Colors.black.withAlpha(opacity); - } - - void _cancelTimers() { - for (final timer in _delayedOperations) { - timer.cancel(); - } - _delayedOperations.clear(); - } - - double _getVerticalOffsetForBottomSheet(double extent) => - (context.height * extent) - (context.height * _kBottomSheetMinimumExtent); - - ImageStream _precacheImage(BaseAsset asset) { - final provider = getFullImageProvider(asset, size: context.sizeData); - return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener); - } - - void _precacheAssets(int index) { - final timelineService = ref.read(timelineServiceProvider); - unawaited(timelineService.preCacheAssets(index)); - _cancelTimers(); - // This will trigger the pre-caching of adjacent assets ensuring - // that they are ready when the user navigates to them. - final timer = Timer(Durations.medium4, () async { - // Check if widget is still mounted before proceeding - if (!mounted) return; - - final (prevAsset, nextAsset) = await ( - timelineService.getAssetAsync(index - 1), - timelineService.getAssetAsync(index + 1), - ).wait; - if (!mounted) return; - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - _prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null; - _nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null; - }); - _delayedOperations.add(timer); - } - - void _onAssetInit(Duration _) { - _precacheAssets(widget.initialIndex); + void _onAssetInit(Duration timeStamp) { + _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); } void _onAssetChanged(int index) async { final timelineService = ref.read(timelineServiceProvider); final asset = await timelineService.getAssetAsync(index); - if (asset == null) { - return; - } + if (asset == null) return; - widget.changeAsset(ref, asset); - _precacheAssets(index); + AssetViewer._setAsset(ref, asset); + _preloader.preload(index, context.sizeData); _handleCasting(); _stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); @@ -238,460 +153,106 @@ class _AssetViewerState extends ConsumerState { final asset = ref.read(currentAssetNotifier); if (asset == null) return; - // hide any casting snackbars if they exist - context.scaffoldMessenger.hideCurrentSnackBar(); - - // send image to casting if the server has it if (asset is RemoteAsset) { + context.scaffoldMessenger.hideCurrentSnackBar(); ref.read(castProvider.notifier).loadMedia(asset, false); - } else { - // casting cannot show local assets - context.scaffoldMessenger.clearSnackBars(); - - if (ref.read(castProvider).isCasting) { - ref.read(castProvider.notifier).stop(); - context.scaffoldMessenger.showSnackBar( - SnackBar( - duration: const Duration(seconds: 2), - content: Text( - "local_asset_cast_failed".tr(), - style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), - ), - ), - ); - } - } - } - - void _onPageBuild(PhotoViewControllerBase controller) { - viewController ??= controller; - if (showingBottomSheet && bottomSheetController.isAttached) { - final verticalOffset = - (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); - controller.position = Offset(0, -verticalOffset); - // Apply the zoom effect when the bottom sheet is showing - controller.scale = (controller.scale ?? 1.0) + 0.01; - } - } - - void _onPageChanged(int index, PhotoViewControllerBase? controller) { - _onAssetChanged(index); - viewController = controller; - } - - void _onDragStart( - _, - DragStartDetails details, - PhotoViewControllerBase controller, - PhotoViewScaleStateController scaleStateController, - ) { - viewController = controller; - dragDownPosition = details.localPosition; - initialPhotoViewState = controller.value; - final isZoomed = - scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || - scaleStateController.scaleState == PhotoViewScaleState.covering; - if (!showingBottomSheet && isZoomed) { - blockGestures = true; - } - } - - void _onDragEnd(BuildContext ctx, _, __) { - dragInProgress = false; - - if (shouldPopOnDrag) { - // Dismiss immediately without state updates to avoid rebuilds - ctx.maybePop(); return; } - // Do not reset the state if the bottom sheet is showing - if (showingBottomSheet) { - _snapBottomSheet(); - return; - } - - // If the gestures are blocked, do not reset the state - if (blockGestures) { - blockGestures = false; - return; - } - - shouldPopOnDrag = false; - hasDraggedDown = null; - viewController?.animateMultiple( - position: initialPhotoViewState.position, - scale: viewController?.initialScale ?? initialPhotoViewState.scale, - rotation: initialPhotoViewState.rotation, + context.scaffoldMessenger.clearSnackBars(); + ref.read(castProvider.notifier).stop(); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "local_asset_cast_failed".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), ); - ref.read(assetViewerProvider.notifier).setOpacity(255); - } - - void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { - if (blockGestures) { - return; - } - - dragInProgress = true; - final delta = details.localPosition - dragDownPosition; - hasDraggedDown ??= delta.dy > 0; - if (!hasDraggedDown! || showingBottomSheet) { - _handleDragUp(ctx, delta); - return; - } - - _handleDragDown(ctx, delta); - } - - void _handleDragUp(BuildContext ctx, Offset delta) { - const double openThreshold = 50; - - final position = initialPhotoViewState.position + Offset(0, delta.dy); - final distanceToOrigin = position.distance; - - viewController?.updateMultiple(position: position); - // Moves the bottom sheet when the asset is being dragged up - if (showingBottomSheet && bottomSheetController.isAttached) { - final centre = (ctx.height * _kBottomSheetMinimumExtent); - bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); - } - - if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) { - _openBottomSheet(ctx); - } - } - - void _handleDragDown(BuildContext ctx, Offset delta) { - const double dragRatio = 0.2; - const double popThreshold = 75; - - final distance = delta.distance; - shouldPopOnDrag = delta.dy > 0 && distance > popThreshold; - - final maxScaleDistance = ctx.height * 0.5; - final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); - double? updatedScale; - double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale; - if (initialScale != null) { - updatedScale = initialScale * (1.0 - scaleReduction); - } - - final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round(); - - viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale); - ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity); - } - - void _onTapDown(_, __, ___) { - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).toggleControls(); - } - } - - bool _onNotification(Notification delta) { - if (delta is DraggableScrollableNotification) { - _handleDraggableNotification(delta); - } - - // Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after - // the isSnapping guard is to prevent the notification from recursively handling the - // notification, eventually resulting in a heap overflow - if (!isSnapping && delta is ScrollEndNotification) { - _snapBottomSheet(); - } - return false; - } - - void _handleDraggableNotification(DraggableScrollableNotification delta) { - final currentExtent = delta.extent; - final isDraggingDown = currentExtent < previousExtent; - previousExtent = currentExtent; - // Closes the bottom sheet if the user is dragging down - if (isDraggingDown && delta.extent < 0.67) { - if (dragInProgress) { - blockGestures = true; - } - // Jump to a lower position before starting close animation to prevent glitch - if (bottomSheetController.isAttached) { - bottomSheetController.jumpTo(0.67); - } - sheetCloseController?.close(); - } - - // If the asset is being dragged down, we do not want to update the asset position again - if (dragInProgress) { - return; - } - - final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent); - // Moves the asset when the bottom sheet is being dragged - if (verticalOffset > 0) { - viewController?.position = Offset(0, -verticalOffset); - } } void _onEvent(Event event) { - if (event is TimelineReloadEvent) { - _onTimelineReloadEvent(); - return; - } - - if (event is ViewerReloadAssetEvent) { - assetReloadRequested = true; - return; - } - - if (event is ViewerOpenBottomSheetEvent) { - final extent = _kBottomSheetMinimumExtent + 0.3; - _openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode); - final offset = _getVerticalOffsetForBottomSheet(extent); - viewController?.position = Offset(0, -offset); - return; + switch (event) { + case TimelineReloadEvent(): + _onTimelineReloadEvent(); + case ViewerReloadAssetEvent(): + _assetReloadRequested = true; + default: } } void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); - totalAssets = timelineService.totalAssets; + final totalAssets = timelineService.totalAssets; if (totalAssets == 0) { context.maybePop(); return; } - var index = pageController.page?.round() ?? 0; + var index = _pageController.page?.round() ?? 0; final currentAsset = ref.read(currentAssetNotifier); if (currentAsset != null) { final newIndex = timelineService.getIndex(currentAsset.heroTag); if (newIndex != null && newIndex != index) { index = newIndex; - pageController.jumpToPage(index); + _pageController.jumpToPage(index); } } if (index >= totalAssets) { index = totalAssets - 1; - pageController.jumpToPage(index); + _pageController.jumpToPage(index); } - if (assetReloadRequested) { - assetReloadRequested = false; + if (_assetReloadRequested) { + _assetReloadRequested = false; _onAssetReloadEvent(index); } } void _onAssetReloadEvent(int index) async { final timelineService = ref.read(timelineServiceProvider); - final newAsset = await timelineService.getAssetAsync(index); - if (newAsset == null) { - return; - } + final newAsset = await timelineService.getAssetAsync(index); + if (newAsset == null) return; final currentAsset = ref.read(currentAssetNotifier); - // Do not reload / close the bottom sheet if the asset has not changed - if (newAsset.heroTag == currentAsset?.heroTag) { - return; - } - setState(() { - _onAssetChanged(pageController.page!.round()); - sheetCloseController?.close(); - }); - } + // Do not reload if the asset has not changed + if (newAsset.heroTag == currentAsset?.heroTag) return; - void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { - ref.read(assetViewerProvider.notifier).setBottomSheet(true); - previousExtent = _kBottomSheetMinimumExtent; - sheetCloseController = showBottomSheet( - context: ctx, - sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2), - constraints: const BoxConstraints(maxWidth: double.infinity), - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))), - backgroundColor: ctx.colorScheme.surfaceContainerLowest, - builder: (_) { - return NotificationListener( - onNotification: _onNotification, - child: activitiesMode - ? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent) - : AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent), - ); - }, - ); - sheetCloseController?.closed.then((_) => _handleSheetClose()); - } - - void _handleSheetClose() { - viewController?.animateMultiple(position: Offset.zero); - viewController?.updateMultiple(scale: viewController?.initialScale); - ref.read(assetViewerProvider.notifier).setBottomSheet(false); - sheetCloseController = null; - shouldPopOnDrag = false; - hasDraggedDown = null; - } - - void _snapBottomSheet() { - if (!bottomSheetController.isAttached || - bottomSheetController.size > _kBottomSheetSnapExtent || - bottomSheetController.size < 0.4) { - return; - } - isSnapping = true; - bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut); - } - - Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { - return const Center(child: ImmichLoadingIndicator()); - } - - void _onScaleStateChanged(PhotoViewScaleState scaleState) { - if (scaleState != PhotoViewScaleState.initial) { - if (!dragInProgress) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - ref.read(videoPlayerControlsProvider.notifier).pause(); - return; - } - - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).setControls(true); - } - } - - void _onLongPress(_, __, ___) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = true; - } - - PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { - scaffoldContext ??= ctx; - final timelineService = ref.read(timelineServiceProvider); - final asset = timelineService.getAssetSafe(index); - - // If asset is not available in buffer, return a placeholder - if (asset == null) { - return PhotoViewGalleryPageOptions.customChild( - heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'), - child: Container( - width: ctx.width, - height: ctx.height, - color: backgroundColor, - child: const Center(child: CircularProgressIndicator()), - ), - ); - } - - BaseAsset displayAsset = asset; - final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren != null && stackChildren.isNotEmpty) { - displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex); - } - - final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); - if (displayAsset.isImage && !isPlayingMotionVideo) { - return _imageBuilder(ctx, displayAsset); - } - - return _videoBuilder(ctx, displayAsset); - } - - PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { - final size = ctx.sizeData; - return PhotoViewGalleryPageOptions( - key: ValueKey(asset.heroTag), - imageProvider: getFullImageProvider(asset, size: size), - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - tightMode: true, - disableScaleGestures: showingBottomSheet, - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, __, ___) => Container( - width: size.width, - height: size.height, - color: backgroundColor, - child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), - ), - ); - } - - GlobalKey _getVideoPlayerKey(String id) { - videoPlayerKeys.putIfAbsent(id, () => GlobalKey()); - return videoPlayerKeys[id]!; - } - - PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - maxScale: 1.0, - basePosition: Alignment.center, - disableScaleGestures: true, - child: SizedBox( - width: ctx.width, - height: ctx.height, - child: NativeVideoViewer( - key: _getVideoPlayerKey(asset.heroTag), - asset: asset, - image: Image( - key: ValueKey(asset), - image: getFullImageProvider(asset, size: ctx.sizeData), - fit: BoxFit.contain, - height: ctx.height, - width: ctx.width, - alignment: Alignment.center, - ), - ), - ), - ); - } - - void _onPop(bool didPop, T? result) { - ref.read(currentAssetNotifier.notifier).dispose(); + _onAssetChanged(index); } @override Widget build(BuildContext context) { - // Rebuild the widget when the asset viewer state changes - // Using multiple selectors to avoid unnecessary rebuilds for other state changes - ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); - ref.watch(assetViewerProvider.select((s) => s.stackIndex)); - ref.watch(isPlayingMotionVideoProvider); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed)); + final backgroundColor = showingDetails + ? context.colorScheme.surface + : Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity))); // Listen for casting changes and send initial asset to the cast provider - ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async { + ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) { if (!isCasting) return; - - final asset = ref.read(currentAssetNotifier); - if (asset == null) return; - WidgetsBinding.instance.addPostFrameCallback((_) { _handleCasting(); }); }); - // Listen for control visibility changes and change system UI mode accordingly - ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async { - if (showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) { + final (controls, details) = state; + final mode = !controls || (CurrentPlatform.isIOS && details) + ? SystemUiMode.immersiveSticky + : SystemUiMode.edgeToEdge; + unawaited(SystemChrome.setEnabledSystemUIMode(mode)); }); - // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. - // Issue: https://github.com/flutter/flutter/issues/109037 - // TODO: Add a custom scrum builder once the fix lands on stable return PopScope( - onPopInvokedWithResult: _onPop, + onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(), child: Scaffold( backgroundColor: backgroundColor, appBar: const ViewerTopAppBar(), @@ -705,33 +266,30 @@ class _AssetViewerState extends ConsumerState { child: const DownloadStatusFloatingButton(), ), ), + bottomNavigationBar: const ViewerBottomAppBar(), body: Stack( children: [ - PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: CurrentPlatform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics(), // Use heavy physics for Android - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: _pageController, + physics: isZoomed + ? const NeverScrollableScrollPhysics() + : CurrentPlatform.isIOS + ? const FastScrollPhysics() + : const FastClampingScrollPhysics(), + itemCount: ref.read(timelineServiceProvider).totalAssets, + onPageChanged: (index) => _onAssetChanged(index), + itemBuilder: (context, index) => + AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), + ), ), - if (!showingBottomSheet) - const Positioned( - bottom: 0, - left: 0, - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], + if (!CurrentPlatform.isIOS) + IgnorePointer( + child: AnimatedContainer( + duration: Durations.short2, + color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), + height: context.padding.top, ), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 36e5bf67d9..dc510d6017 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:riverpod_annotation/riverpod_annotation.dart'; class AssetViewerState { - final int backgroundOpacity; - final bool showingBottomSheet; + final double backgroundOpacity; + final bool showingDetails; final bool showingControls; + final bool isZoomed; final BaseAsset? currentAsset; final int stackIndex; const AssetViewerState({ - this.backgroundOpacity = 255, - this.showingBottomSheet = false, + this.backgroundOpacity = 1.0, + this.showingDetails = false, this.showingControls = true, + this.isZoomed = false, this.currentAsset, this.stackIndex = 0, }); AssetViewerState copyWith({ - int? backgroundOpacity, - bool? showingBottomSheet, + double? backgroundOpacity, + bool? showingDetails, bool? showingControls, + bool? isZoomed, BaseAsset? currentAsset, int? stackIndex, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, - showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, + showingDetails: showingDetails ?? this.showingDetails, showingControls: showingControls ?? this.showingControls, + isZoomed: isZoomed ?? this.isZoomed, currentAsset: currentAsset ?? this.currentAsset, stackIndex: stackIndex ?? this.stackIndex, ); @@ -35,7 +39,7 @@ class AssetViewerState { @override String toString() { - return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)'; + return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)'; } @override @@ -44,8 +48,9 @@ class AssetViewerState { if (other.runtimeType != runtimeType) return false; return other is AssetViewerState && other.backgroundOpacity == backgroundOpacity && - other.showingBottomSheet == showingBottomSheet && + other.showingDetails == showingDetails && other.showingControls == showingControls && + other.isZoomed == isZoomed && other.currentAsset == currentAsset && other.stackIndex == stackIndex; } @@ -53,8 +58,9 @@ class AssetViewerState { @override int get hashCode => backgroundOpacity.hashCode ^ - showingBottomSheet.hashCode ^ + showingDetails.hashCode ^ showingControls.hashCode ^ + isZoomed.hashCode ^ currentAsset.hashCode ^ stackIndex.hashCode; } @@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(currentAsset: asset, stackIndex: 0); } - void setOpacity(int opacity) { + void setOpacity(double opacity) { if (opacity == state.backgroundOpacity) { return; } - state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls); + state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls); } - void setBottomSheet(bool showing) { - if (showing == state.showingBottomSheet) { + void setShowingDetails(bool showing) { + if (showing == state.showingDetails) { return; } - state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls); + state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); if (showing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } @@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(showingControls: !state.showingControls); } + void setZoomed(bool isZoomed) { + if (isZoomed == state.isZoomed) { + return; + } + state = state.copyWith(isZoomed: isZoomed); + } + void setStackIndex(int index) { if (index == state.stackIndex) { return; diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 537f2fc31d..93006ab978 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; - final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - if (!showControls) { - opacity = 0; - } - final originalTheme = context.themeData; final actions = [ @@ -56,37 +50,30 @@ class ViewerBottomBar extends ConsumerWidget { ], ]; - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: AnimatedSwitcher( - duration: Durations.short4, - child: isSheetOpen - ? const SizedBox.shrink() - : Theme( - data: context.themeData.copyWith( - iconTheme: const IconThemeData(size: 22, color: Colors.white), - textTheme: context.themeData.textTheme.copyWith( - labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), - ), - ), - child: Container( - color: Colors.black.withAlpha(125), - padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (asset.isVideo) const VideoControls(), - if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), - ], - ), - ), + return AnimatedSwitcher( + duration: Durations.short4, + child: showingDetails + ? const SizedBox.shrink() + : Theme( + data: context.themeData.copyWith( + iconTheme: const IconThemeData(size: 22, color: Colors.white), + textTheme: context.themeData.textTheme.copyWith( + labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), ), - ), - ), + ), + child: Container( + color: Colors.black.withAlpha(125), + padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (asset.isVideo) const VideoControls(), + if (!isReadonlyModeEnabled) + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ], + ), + ), + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart deleted file mode 100644 index 2e10e6856b..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/repositories/asset_media.repository.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/bytes_units.dart'; -import 'package:immich_mobile/utils/timezone.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; - -const _kSeparator = ' â€ĸ '; - -class AssetDetailBottomSheet extends ConsumerWidget { - final DraggableScrollableController? controller; - final double initialChildSize; - - const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - return BaseBottomSheet( - actions: [], - slivers: const [_AssetDetailBottomSheet()], - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} - -class _AssetDetailBottomSheet extends ConsumerWidget { - const _AssetDetailBottomSheet(); - - String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { - DateTime dateTime = asset.createdAt.toLocal(); - Duration timeZoneOffset = dateTime.timeZoneOffset; - - // Use EXIF timezone information if available (matching web app behavior) - if (exifInfo?.dateTimeOriginal != null) { - (dateTime, timeZoneOffset) = applyTimezoneOffset( - dateTime: exifInfo!.dateTimeOriginal!, - timeZone: exifInfo.timeZone, - ); - } - - final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); - final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; - return '$date$_kSeparator$time $timezone'; - } - - String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { - final height = asset.height; - final width = asset.width; - final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; - final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; - - return switch ((fileSize, resolution)) { - (null, null) => '', - (String fileSize, null) => fileSize, - (null, String resolution) => resolution, - (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', - }; - } - - String? _getCameraInfoTitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - - return switch ((exifInfo.make, exifInfo.model)) { - (null, null) => null, - (String make, null) => make, - (null, String model) => model, - (String make, String model) => '$make $model', - }; - } - - String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; - final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; - return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); - } - - String? _getLensInfoSubtitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; - final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; - return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); - } - - Future _editDateTime(BuildContext context, WidgetRef ref) async { - await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); - } - - Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - if (!asset.hasRemote) { - return const SizedBox.shrink(); - } - - String? remoteAssetId; - if (asset is RemoteAsset) { - remoteAssetId = asset.id; - } else if (asset is LocalAsset) { - remoteAssetId = asset.remoteAssetId; - } - - if (remoteAssetId == null) { - return const SizedBox.shrink(); - } - - final userId = ref.watch(currentUserProvider)?.id; - final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); - - return assetAlbums.when( - data: (albums) { - if (albums.isEmpty) { - return const SizedBox.shrink(); - } - - albums.sortBy((a) => a.name); - - return Column( - spacing: 12, - children: [ - if (albums.isNotEmpty) - SheetTile( - title: 'appears_in'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - Padding( - padding: const EdgeInsets.only(left: 24), - child: Column( - spacing: 12, - children: albums.map((album) { - final isOwner = album.ownerId == userId; - return AlbumTile( - album: album, - isOwner: isOwner, - onAlbumSelected: (album) async { - ref.invalidate(assetViewerProvider); - unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); - }, - ); - }).toList(), - ), - ), - ], - ); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - final cameraTitle = _getCameraInfoTitle(exifInfo); - final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; - final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); - - // Build file info tile based on asset type - Widget buildFileInfoTile() { - if (asset is LocalAsset) { - final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); - return FutureBuilder( - future: assetMediaRepository.getOriginalFilename(asset.id), - builder: (context, snapshot) { - final displayName = snapshot.data ?? asset.name; - return SheetTile( - title: displayName, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - }, - ); - } else { - // For remote assets, use the name directly - return SheetTile( - title: asset.name, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - } - } - - return SliverList.list( - children: [ - // Asset Date and Time - SheetTile( - title: _getDateTime(context, asset, exifInfo), - titleStyle: context.textTheme.labelLarge, - trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, - onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, - ), - if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), - const SheetPeopleDetails(), - const SheetLocationDetails(), - // Details header - SheetTile( - title: 'details'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - // File info - buildFileInfoTile(), - // Camera info - if (cameraTitle != null) ...[ - const SizedBox(height: 16), - SheetTile( - title: cameraTitle, - titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), - subtitle: _getCameraInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - ], - // Lens info - if (lensTitle != null) ...[ - const SizedBox(height: 16), - SheetTile( - title: lensTitle, - titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), - subtitle: _getLensInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - ], - // Rating bar - if (isRatingEnabled) ...[ - Padding( - padding: const EdgeInsets.only(left: 16.0, top: 16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - 'rating'.t(context: context), - style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - RatingBar( - initialRating: exifInfo?.rating?.toDouble() ?? 0, - filledColor: context.themeData.colorScheme.primary, - unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), - itemSize: 40, - onRatingUpdate: (rating) async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); - }, - onClearRating: () async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); - }, - ), - ], - ), - ), - ], - // Appears in (Albums) - Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), - // padding at the bottom to avoid cut-off - const SizedBox(height: 60), - ], - ); - } -} - -class _SheetAssetDescription extends ConsumerStatefulWidget { - final ExifInfo exif; - final bool isEditable; - - const _SheetAssetDescription({required this.exif, this.isEditable = true}); - - @override - ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); -} - -class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { - late TextEditingController _controller; - final _descriptionFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.exif.description ?? ''); - } - - Future saveDescription(String? previousDescription) async { - final newDescription = _controller.text.trim(); - - if (newDescription == previousDescription) { - _descriptionFocus.unfocus(); - return; - } - - final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); - - if (!editAction.success) { - _controller.text = previousDescription ?? ''; - - ImmichToast.show( - context: context, - msg: 'exif_bottom_sheet_description_error'.t(context: context), - toastType: ToastType.error, - ); - } - - _descriptionFocus.unfocus(); - } - - @override - Widget build(BuildContext context) { - // Watch the current asset EXIF provider to get updates - final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - - // Update controller text when EXIF data changes - final currentDescription = currentExifInfo?.description ?? ''; - final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( - context: context, - ); - if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { - _controller.text = currentDescription; - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: IgnorePointer( - ignoring: !widget.isEditable, - child: TextField( - controller: _controller, - keyboardType: TextInputType.multiline, - focusNode: _descriptionFocus, - maxLines: null, // makes it grow as text is added - decoration: InputDecoration( - hintText: hintText, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - ), - onTapOutside: (_) => saveDescription(currentExifInfo?.description), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 538a9bde20..0f6568e8fd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -19,12 +20,13 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/hooks/interval_hook.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -52,6 +54,8 @@ class NativeVideoViewer extends HookConsumerWidget { final bool showControls; final int playbackDelayFactor; final Widget image; + final ValueNotifier? scaleStateNotifier; + final bool disableScaleGestures; const NativeVideoViewer({ super.key, @@ -59,6 +63,8 @@ class NativeVideoViewer extends HookConsumerWidget { required this.image, this.showControls = true, this.playbackDelayFactor = 1, + this.scaleStateNotifier, + this.disableScaleGestures = false, }); @override @@ -138,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoSource = useMemoized>(() => createSource()); final aspectRatio = useState(null); + useMemoized(() async { if (!context.mounted || aspectRatio.value != null) { return null; @@ -205,7 +212,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) { + if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) { return; } @@ -313,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget { Timer(const Duration(milliseconds: 200), checkIfBuffering); } + Size? videoContextSize(double? videoAspectRatio, BuildContext? context) { + Size? videoContextSize; + if (videoAspectRatio == null || context == null) { + return null; + } + final contextAspectRatio = context.width / context.height; + if (videoAspectRatio > contextAspectRatio) { + videoContextSize = Size(context.width, context.width / aspectRatio.value!); + } else { + videoContextSize = Size(context.height * aspectRatio.value!, context.height); + } + return videoContextSize; + } + ref.listen(currentAssetNotifier, (_, value) { final playerController = controller.value; if (playerController != null && value != asset) { @@ -393,26 +414,31 @@ class NativeVideoViewer extends HookConsumerWidget { } }); - return Stack( - children: [ - // This remains under the video to avoid flickering - // For motion videos, this is the image portion of the asset - Center(key: ValueKey(asset.heroTag), child: image), - if (aspectRatio.value != null && !isCasting) - Visibility.maintain( - key: ValueKey(asset), - visible: isVisible.value, - child: Center( + return SizedBox( + width: context.width, + height: context.height, + child: Stack( + children: [ + // Hide thumbnail once video is visible to avoid it showing in background when zooming out on video. + if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image), + if (aspectRatio.value != null && !isCasting && isCurrent) + Visibility.maintain( key: ValueKey(asset), - child: AspectRatio( + visible: isVisible.value, + child: PhotoView.customChild( key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null, + enableRotation: false, + disableScaleGestures: disableScaleGestures, + // Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet. + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state, + childSize: videoContextSize(aspectRatio.value, context), + child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController), ), ), - ), - if (showControls) const Center(child: VideoViewerControls()), - ], + if (showControls) const Center(child: VideoViewerControls()), + ], + ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index c1324b8ac0..28cfe5e73c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; @@ -19,8 +19,8 @@ class VideoViewerControls extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - if (showBottomSheet) { + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { showControls = false; } final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); @@ -81,27 +81,35 @@ class VideoViewerControls extends HookConsumerWidget { } } + void toggleControlsVisibility() { + if (showBuffering) { + return; + } + if (showControls) { + ref.read(assetViewerProvider.notifier).setControls(false); + } else { + showControlsAndStartHideTimer(); + } + } + return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: showControlsAndStartHideTimer, - child: AbsorbPointer( - absorbing: !showControls, + behavior: HitTestBehavior.translucent, + onTap: toggleControlsVisibility, + child: IgnorePointer( + ignoring: !showControls, child: Stack( children: [ if (showBuffering) const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400))) else - GestureDetector( - onTap: () => ref.read(assetViewerProvider.notifier).setControls(false), - child: CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, - isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), - show: assetIsVideo && showControls, - onPressed: togglePlay, - ), + CenterPlayButton( + backgroundColor: Colors.black54, + iconColor: Colors.white, + isFinished: state == VideoPlaybackState.completed, + isPlaying: + state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + show: assetIsVideo && showControls, + onPressed: togglePlay, ), ], ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart new file mode 100644 index 0000000000..aa3b8bb93f --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; + +class ViewerBottomAppBar extends ConsumerWidget { + const ViewerBottomAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0.0; + } + + return IgnorePointer( + ignoring: opacity < 1.0, + child: AnimatedOpacity( + opacity: opacity, + duration: Durations.short2, + child: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [AssetStackRow(), ViewerBottomBar()], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 10f3595d01..fb25e9e1cb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart similarity index 80% rename from mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 193cf60220..4b748abc27 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -3,16 +3,15 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -35,8 +34,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isInLockedView = ref.watch(inLockedViewProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { @@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { } if (!showControls) { - opacity = 0; + opacity = 0.0; } final originalTheme = context.themeData; @@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { IconButton( icon: const Icon(Icons.chat_outlined), onPressed: () { - EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); + context.router.push( + DriftActivitiesRoute( + album: album, + assetId: asset is RemoteAsset ? asset.id : null, + assetName: asset.name, + ), + ); }, ), @@ -70,17 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final lockedViewActions = [ViewerKebabMenu(originalTheme: originalTheme)]; return IgnorePointer( - ignoring: opacity < 255, + ignoring: opacity < 1.0, child: AnimatedOpacity( - opacity: opacity / 255, + opacity: opacity, duration: Durations.short2, child: AppBar( - backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125), + backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5), leading: const _AppBarBackButton(), iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet || isReadonlyModeEnabled + actions: showingDetails || isReadonlyModeEnabled ? null : isInLockedView ? lockedViewActions @@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white; + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; + final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; return Padding( padding: const EdgeInsets.only(left: 12.0), @@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget { iconSize: 22, iconColor: foregroundColor, padding: EdgeInsets.zero, - elevation: isShowingSheet ? 4 : 0, + elevation: showingDetails ? 4 : 0, ), onPressed: context.maybePop, child: const Icon(Icons.arrow_back_rounded), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 2f2a2e0a4e..6848a07bb8 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; @@ -113,6 +114,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState ], if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline), if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id), + if (ownsAlbum && multiselect.selectedAssets.length == 1) + SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id), ], slivers: ownsAlbum ? [ diff --git a/mobile/lib/presentation/widgets/timeline/fixed/row.dart b/mobile/lib/presentation/widgets/timeline/fixed/row.dart index 3fe3cea3c9..97067add24 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/row.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/row.dart @@ -1,27 +1,45 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -class FixedTimelineRow extends MultiChildRenderObjectWidget { - final double dimension; +class TimelineRow extends MultiChildRenderObjectWidget { + final double height; + final List widths; final double spacing; final TextDirection textDirection; - const FixedTimelineRow({ + const TimelineRow({ super.key, - required this.dimension, + required this.height, + required this.widths, required this.spacing, required this.textDirection, required super.children, }); + factory TimelineRow.fixed({ + required double dimension, + required double spacing, + required TextDirection textDirection, + required List children, + }) => TimelineRow( + height: dimension, + widths: List.filled(children.length, dimension), + spacing: spacing, + textDirection: textDirection, + children: children, + ); + @override RenderObject createRenderObject(BuildContext context) { - return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection); + return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection); } @override void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { - renderObject.dimension = dimension; + renderObject.height = height; + renderObject.widths = widths; renderObject.spacing = spacing; renderObject.textDirection = textDirection; } @@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('height', height)); + properties.add(DiagnosticsProperty>('widths', widths)); properties.add(DoubleProperty('spacing', spacing)); properties.add(EnumProperty('textDirection', textDirection)); } @@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox RenderBoxContainerDefaultsMixin { RenderFixedRow({ List? children, - required double dimension, + required double height, + required List widths, required double spacing, required TextDirection textDirection, - }) : _dimension = dimension, + }) : _height = height, + _widths = widths, _spacing = spacing, _textDirection = textDirection { addAll(children); } - double get dimension => _dimension; - double _dimension; + double get height => _height; + double _height; - set dimension(double value) { - if (_dimension == value) return; - _dimension = value; + set height(double value) { + if (_height == value) return; + _height = value; + markNeedsLayout(); + } + + List get widths => _widths; + List _widths; + + set widths(List value) { + if (listEquals(_widths, value)) return; + _widths = value; markNeedsLayout(); } @@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox } } - double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1); + double get intrinsicWidth => widths.sum + (spacing * (childCount - 1)); @override double computeMinIntrinsicWidth(double height) => intrinsicWidth; @@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox double computeMaxIntrinsicWidth(double height) => intrinsicWidth; @override - double computeMinIntrinsicHeight(double width) => dimension; + double computeMinIntrinsicHeight(double width) => height; @override - double computeMaxIntrinsicHeight(double width) => dimension; + double computeMaxIntrinsicHeight(double width) => height; @override double? computeDistanceToActualBaseline(TextBaseline baseline) { @@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('height', height)); + properties.add(DiagnosticsProperty>('widths', widths)); properties.add(DoubleProperty('spacing', spacing)); properties.add(EnumProperty('textDirection', textDirection)); } @@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox return; } // Use the entire width of the parent for the row. - size = Size(constraints.maxWidth, dimension); - // Each tile is forced to be dimension x dimension. - final childConstraints = BoxConstraints.tight(Size(dimension, dimension)); + size = Size(constraints.maxWidth, height); + final flipMainAxis = textDirection == TextDirection.rtl; - Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0); - final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing); + int childIndex = 0; + double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0; // Layout each child horizontally. - while (child != null) { + while (child != null && childIndex < widths.length) { + final width = widths[childIndex]; + final childConstraints = BoxConstraints.tight(Size(width, height)); child.layout(childConstraints, parentUsesSize: false); final childParentData = child.parentData! as _RowParentData; - childParentData.offset = offset; - offset += Offset(dx, 0); + childParentData.offset = Offset(currentX, 0); child = childParentData.nextSibling; + childIndex++; + + if (child != null && childIndex < widths.length) { + final nextWidth = widths[childIndex]; + currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing; + } } } } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index b879b33f68..aa2112b8dd 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'dart:math' as math; 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/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; @@ -78,6 +80,7 @@ class FixedSegment extends Segment { assetCount: numberOfAssets, tileHeight: tileHeight, spacing: spacing, + columnCount: columnCount, ); } } @@ -87,24 +90,32 @@ class _FixedSegmentRow extends ConsumerWidget { final int assetCount; final double tileHeight; final double spacing; + final int columnCount; const _FixedSegmentRow({ required this.assetIndex, required this.assetCount, required this.tileHeight, required this.spacing, + required this.columnCount, }); @override Widget build(BuildContext context, WidgetRef ref) { final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final timelineService = ref.read(timelineServiceProvider); + final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); if (isScrubbing) { return _buildPlaceholder(context); } if (timelineService.hasRange(assetIndex, assetCount)) { - return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService); + return _buildAssetRow( + context, + timelineService.getAssets(assetIndex, assetCount), + timelineService, + isDynamicLayout, + ); } return FutureBuilder>( @@ -113,7 +124,7 @@ class _FixedSegmentRow extends ConsumerWidget { if (snapshot.connectionState != ConnectionState.done) { return _buildPlaceholder(context); } - return _buildAssetRow(context, snapshot.requireData, timelineService); + return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout); }, ); } @@ -122,23 +133,58 @@ class _FixedSegmentRow extends ConsumerWidget { return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing); } - Widget _buildAssetRow(BuildContext context, List assets, TimelineService timelineService) { - return FixedTimelineRow( - dimension: tileHeight, - spacing: spacing, - textDirection: Directionality.of(context), - children: [ - for (int i = 0; i < assets.length; i++) - TimelineAssetIndexWrapper( + Widget _buildAssetRow( + BuildContext context, + List assets, + TimelineService timelineService, + bool isDynamicLayout, + ) { + final children = [ + for (int i = 0; i < assets.length; i++) + TimelineAssetIndexWrapper( + assetIndex: assetIndex + i, + segmentIndex: 0, // For simplicity, using 0 for now + child: _AssetTileWidget( + key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), + asset: assets[i], assetIndex: assetIndex + i, - segmentIndex: 0, // For simplicity, using 0 for now - child: _AssetTileWidget( - key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), - asset: assets[i], - assetIndex: assetIndex + i, - ), ), - ], + ), + ]; + + final widths = List.filled(assets.length, tileHeight); + + if (isDynamicLayout) { + final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList(); + final meanAspectRatio = aspectRatios.sum / assets.length; + + // 1: mean width + // 0.5: width < mean - threshold + // 1.5: width > mean + threshold + final arConfiguration = aspectRatios.map((e) { + if (e - meanAspectRatio > 0.3) return 1.5; + if (e - meanAspectRatio < -0.3) return 0.5; + return 1.0; + }); + + // Normalize to get width distribution + final sum = arConfiguration.sum; + + int index = 0; + for (final ratio in arConfiguration) { + // Distribute the available width proportionally based on aspect ratio configuration + widths[index++] = ((ratio * assets.length) / sum) * tileHeight; + } + } + + return TimelineDragRegion( + child: TimelineRow( + height: tileHeight, + widths: widths, + spacing: spacing, + textDirection: Directionality.of(context), + children: children, + ), ); } } diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart index 79ffb47e95..442d42d536 100644 --- a/mobile/lib/presentation/widgets/timeline/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -24,7 +24,7 @@ abstract class SegmentBuilder { Size size = kTimelineFixedTileExtent, double spacing = kTimelineSpacing, }) => RepaintBoundary( - child: FixedTimelineRow( + child: TimelineRow.fixed( dimension: size.height, spacing: spacing, textDirection: Directionality.of(context), diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index ac20e73190..5190e2007f 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,7 +29,38 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; -class Timeline extends StatelessWidget { +class _TimelineRestorationState extends ChangeNotifier { + int? _restoreAssetIndex; + bool _shouldRestoreAssetPosition = false; + + int? get restoreAssetIndex => _restoreAssetIndex; + bool get shouldRestoreAssetPosition => _shouldRestoreAssetPosition; + + void setRestoreAssetIndex(int? index) { + _restoreAssetIndex = index; + notifyListeners(); + } + + void setShouldRestoreAssetPosition(bool should) { + _shouldRestoreAssetPosition = should; + notifyListeners(); + } + + void clearRestoreAssetIndex() { + _restoreAssetIndex = null; + notifyListeners(); + } +} + +class _TimelineRestorationProvider extends InheritedNotifier<_TimelineRestorationState> { + const _TimelineRestorationProvider({required super.notifier, required super.child}); + + static _TimelineRestorationState of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_TimelineRestorationProvider>()!.notifier!; + } +} + +class Timeline extends StatefulWidget { const Timeline({ super.key, this.topSliverWidget, @@ -43,6 +74,7 @@ class Timeline extends StatelessWidget { this.snapToMonth = true, this.initialScrollOffset, this.readOnly = false, + this.persistentBottomBar = false, }); final Widget? topSliverWidget; @@ -56,6 +88,27 @@ class Timeline extends StatelessWidget { final bool snapToMonth; final double? initialScrollOffset; final bool readOnly; + final bool persistentBottomBar; + + @override + State createState() => _TimelineState(); +} + +class _TimelineState extends State { + double? _lastWidth; + late final _TimelineRestorationState _restorationState; + + @override + void initState() { + super.initState(); + _restorationState = _TimelineRestorationState(); + } + + @override + void dispose() { + _restorationState.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -63,30 +116,42 @@ class Timeline extends StatelessWidget { resizeToAvoidBottomInset: false, floatingActionButton: const DownloadStatusFloatingButton(), body: LayoutBuilder( - builder: (_, constraints) => ProviderScope( - overrides: [ - timelineArgsProvider.overrideWith( - (ref) => TimelineArgs( - maxWidth: constraints.maxWidth, - maxHeight: constraints.maxHeight, - columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), - showStorageIndicator: showStorageIndicator, - withStack: withStack, - groupBy: groupBy, + builder: (_, constraints) { + if (_lastWidth != null && _lastWidth != constraints.maxWidth) { + _restorationState.setShouldRestoreAssetPosition(true); + } + _lastWidth = constraints.maxWidth; + return _TimelineRestorationProvider( + notifier: _restorationState, + child: ProviderScope( + key: ValueKey(_lastWidth), + overrides: [ + timelineArgsProvider.overrideWith( + (ref) => TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), + showStorageIndicator: widget.showStorageIndicator, + withStack: widget.withStack, + groupBy: widget.groupBy, + ), + ), + if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), + ], + child: _SliverTimeline( + key: const ValueKey('_sliver_timeline'), + topSliverWidget: widget.topSliverWidget, + topSliverWidgetHeight: widget.topSliverWidgetHeight, + appBar: widget.appBar, + bottomSheet: widget.bottomSheet, + withScrubber: widget.withScrubber, + persistentBottomBar: widget.persistentBottomBar, + snapToMonth: widget.snapToMonth, + initialScrollOffset: widget.initialScrollOffset, ), ), - if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), - ], - child: _SliverTimeline( - topSliverWidget: topSliverWidget, - topSliverWidgetHeight: topSliverWidgetHeight, - appBar: appBar, - bottomSheet: bottomSheet, - withScrubber: withScrubber, - snapToMonth: snapToMonth, - initialScrollOffset: initialScrollOffset, - ), - ), + ); + }, ), ); } @@ -105,11 +170,13 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier { class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ + super.key, this.topSliverWidget, this.topSliverWidgetHeight, this.appBar, this.bottomSheet, this.withScrubber = true, + this.persistentBottomBar = false, this.snapToMonth = true, this.initialScrollOffset, }); @@ -119,6 +186,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; + final bool persistentBottomBar; final bool snapToMonth; final double? initialScrollOffset; @@ -139,14 +207,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { int _perRow = 4; double _scaleFactor = 3.0; double _baseScaleFactor = 3.0; - int? _scaleRestoreAssetIndex; @override void initState() { super.initState(); _scrollController = ScrollController( initialScrollOffset: widget.initialScrollOffset ?? 0.0, - onAttach: _restoreScalePosition, + onAttach: _restoreAssetPosition, ); _eventSubscription = EventStream.shared.listen(_onEvent); @@ -179,14 +246,17 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { EventStream.shared.emit(MultiSelectToggleEvent(isEnabled)); } - void _restoreScalePosition(_) { - if (_scaleRestoreAssetIndex == null) return; + void _restoreAssetPosition(_) { + final restorationState = _TimelineRestorationProvider.of(context); + if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return; final asyncSegments = ref.read(timelineSegmentProvider); asyncSegments.whenData((segments) { - final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _scaleRestoreAssetIndex!); + final targetSegment = segments.lastWhereOrNull( + (segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!, + ); if (targetSegment != null) { - final assetIndexInSegment = _scaleRestoreAssetIndex! - targetSegment.firstAssetIndex; + final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex; final newColumnCount = ref.read(timelineArgsProvider).columnCount; final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor(); final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment; @@ -198,7 +268,25 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }); } }); - _scaleRestoreAssetIndex = null; + restorationState.clearRestoreAssetIndex(); + } + + int? _getCurrentAssetIndex(List segments) { + final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent); + final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull; + int? targetAssetIndex; + if (segment != null) { + final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset); + if (rowIndex > segment.firstIndex) { + final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); + final assetsPerRow = ref.read(timelineArgsProvider).columnCount; + final assetIndexInSegment = rowIndexInSegment * assetsPerRow; + targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment; + } else { + targetAssetIndex = segment.firstAssetIndex; + } + } + return targetAssetIndex; } @override @@ -321,6 +409,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled; + final isBottomWidgetVisible = + widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar); return PopScope( canPop: !isMultiSelectEnabled, @@ -387,74 +478,67 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return PrimaryScrollController( controller: _scrollController, - child: RawGestureDetector( - gestures: { - CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => CustomScaleGestureRecognizer(), - (CustomScaleGestureRecognizer scale) { - scale.onStart = (details) { - _baseScaleFactor = _scaleFactor; - }; - - scale.onUpdate = (details) { - final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); - final newPerRow = 7 - newScaleFactor.toInt(); - - if (newPerRow != _perRow) { - final currentOffset = _scrollController.offset.clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull; - int? targetAssetIndex; - if (segment != null) { - final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset); - if (rowIndex > segment.firstIndex) { - final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); - final assetsPerRow = ref.read(timelineArgsProvider).columnCount; - final assetIndexInSegment = rowIndexInSegment * assetsPerRow; - targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment; - } else { - targetAssetIndex = segment.firstAssetIndex; - } - } - - setState(() { - _scaleFactor = newScaleFactor; - _perRow = newPerRow; - _scaleRestoreAssetIndex = targetAssetIndex; - }); - - ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); - } - }; - }, - ), + child: NotificationListener( + onNotification: (notification) { + final currentIndex = _getCurrentAssetIndex(segments); + if (currentIndex != null && mounted) { + _TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex); + } + return false; }, - child: TimelineDragRegion( - onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, - onAssetEnter: _handleDragAssetEnter, - onEnd: !isReadonlyModeEnabled ? _stopDrag : null, - onScroll: _dragScroll, - onScrollStart: () { - // Minimize the bottom sheet when drag selection starts - ref.read(timelineStateProvider.notifier).setScrolling(true); + child: RawGestureDetector( + gestures: { + CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => CustomScaleGestureRecognizer(), + (CustomScaleGestureRecognizer scale) { + scale.onStart = (details) { + _baseScaleFactor = _scaleFactor; + }; + + scale.onUpdate = (details) { + final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); + final newPerRow = 7 - newScaleFactor.toInt(); + final targetAssetIndex = _getCurrentAssetIndex(segments); + + if (newPerRow != _perRow) { + final restorationState = _TimelineRestorationProvider.of(context); + setState(() { + _scaleFactor = newScaleFactor; + _perRow = newPerRow; + }); + + restorationState.setRestoreAssetIndex(targetAssetIndex); + restorationState.setShouldRestoreAssetPosition(true); + ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); + } + }; + }, + ), }, - child: Stack( - children: [ - timeline, - if (!isSelectionMode && isMultiSelectEnabled) ...[ - Positioned( - top: MediaQuery.paddingOf(context).top, - left: 25, - child: const SizedBox( - height: kToolbarHeight, - child: Center(child: _MultiSelectStatusButton()), + child: TimelineDragRegion( + onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, + onAssetEnter: _handleDragAssetEnter, + onEnd: !isReadonlyModeEnabled ? _stopDrag : null, + onScroll: _dragScroll, + onScrollStart: () { + // Minimize the bottom sheet when drag selection starts + ref.read(timelineStateProvider.notifier).setScrolling(true); + }, + child: Stack( + children: [ + timeline, + if (isMultiSelectStatusVisible) + Positioned( + top: MediaQuery.paddingOf(context).top, + left: 25, + child: const SizedBox( + height: kToolbarHeight, + child: Center(child: _MultiSelectStatusButton()), + ), ), - ), - if (widget.bottomSheet != null) widget.bottomSheet!, + if (isBottomWidgetVisible) widget.bottomSheet!, ], - ], + ), ), ), ), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 75f40ca290..c06bcabf26 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -343,6 +343,22 @@ class ActionNotifier extends Notifier { } } + Future setAlbumCover(ActionSource source, String albumId) async { + final assets = _getAssets(source); + final asset = assets.first; + if (asset is! RemoteAsset) { + return const ActionResult(count: 1, success: false, error: 'Asset must be remote'); + } + + try { + await _service.setAlbumCover(albumId, asset.id); + return const ActionResult(count: 1, success: true); + } catch (error, stack) { + _logger.severe('Failed to set album cover', error, stack); + return ActionResult(count: 1, success: false, error: error.toString()); + } + } + Future updateDescription(ActionSource source, String description) async { final ids = _getRemoteIdsForSource(source); if (ids.length != 1) { diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart similarity index 85% rename from mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart rename to mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart index 1956170c1e..5718333759 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart @@ -31,6 +31,18 @@ class CurrentAssetNotifier extends AutoDisposeNotifier { } } +class ScopedAssetNotifier extends CurrentAssetNotifier { + final BaseAsset _asset; + + ScopedAssetNotifier(this._asset); + + @override + BaseAsset? build() { + setAsset(_asset); + return _asset; + } +} + final currentAssetExifProvider = FutureProvider.autoDispose((ref) { final currentAsset = ref.watch(currentAssetNotifier); if (currentAsset == null) { diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart new file mode 100644 index 0000000000..23d4d86861 --- /dev/null +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -0,0 +1,17 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart'; + +class TagNotifier extends AsyncNotifier> { + @override + Future> build() async { + final repo = ref.read(tagsApiRepositoryProvider); + final allTags = await repo.getAllTags(); + if (allTags == null) { + return {}; + } + return allTags.map((t) => Tag.fromDto(t)).toSet(); + } +} + +final tagProvider = AsyncNotifierProvider>(TagNotifier.new); diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index fba4fa7294..98300894f9 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -15,7 +15,6 @@ class ServerInfoNotifier extends StateNotifier { : super( const ServerInfo( serverVersion: ServerVersion(major: 0, minor: 0, patch: 0), - latestVersion: null, serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true), serverConfig: ServerConfig( trashDays: 30, @@ -104,7 +103,9 @@ final serverInfoProvider = StateNotifierProvider final versionWarningPresentProvider = Provider.family((ref, user) { final serverInfo = ref.watch(serverInfoProvider); - return serverInfo.versionStatus == VersionStatus.clientOutOfDate || - serverInfo.versionStatus == VersionStatus.error || - ((user?.isAdmin ?? false) && serverInfo.versionStatus == VersionStatus.serverOutOfDate); + return switch (serverInfo.versionStatus) { + VersionStatus.clientOutOfDate || VersionStatus.error => true, + VersionStatus.serverOutOfDate => serverInfo.latestVersion != null && (user?.isAdmin ?? false), + VersionStatus.upToDate => false, + }; }); diff --git a/mobile/lib/providers/upload_profile_image.provider.dart b/mobile/lib/providers/upload_profile_image.provider.dart index 5aa924ed1c..a2b7a23f05 100644 --- a/mobile/lib/providers/upload_profile_image.provider.dart +++ b/mobile/lib/providers/upload_profile_image.provider.dart @@ -61,10 +61,10 @@ class UploadProfileImageNotifier extends StateNotifier final UserService _userService; - Future upload(XFile file) async { + Future upload(XFile file, {String? fileName}) async { state = state.copyWith(status: UploadProfileStatus.loading); - var profileImagePath = await _userService.createProfileImage(file.name, await file.readAsBytes()); + var profileImagePath = await _userService.createProfileImage(fileName ?? file.name, await file.readAsBytes()); if (profileImagePath != null) { dPrint(() => "Successfully upload profile image"); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9468b105e5..b385bcbf71 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; -import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart'; import 'package:immich_mobile/presentation/pages/download_info.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; @@ -88,7 +88,6 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; -import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; @@ -107,6 +106,7 @@ import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart'; +import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; @@ -165,7 +165,7 @@ class AppRouter extends RootStackRouter { late final List routes = [ AutoRoute(page: SplashScreenRoute.page, initial: true), AutoRoute(page: PermissionOnboardingRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]), + AutoRoute(page: LoginRoute.page), AutoRoute(page: ChangePasswordRoute.page), AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false), AutoRoute( @@ -199,6 +199,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), AutoRoute(page: FilterImageRoute.page), + AutoRoute(page: ProfilePictureCropRoute.page), CustomRoute( page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard], @@ -338,7 +339,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b287d73114..2d57c16573 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo { DriftActivitiesRoute({ Key? key, required RemoteAlbum album, + String? assetId, + String? assetName, List? children, }) : super( DriftActivitiesRoute.name, - args: DriftActivitiesRouteArgs(key: key, album: album), + args: DriftActivitiesRouteArgs( + key: key, + album: album, + assetId: assetId, + assetName: assetName, + ), initialChildren: children, ); @@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return DriftActivitiesPage(key: args.key, album: args.album); + return DriftActivitiesPage( + key: args.key, + album: args.album, + assetId: args.assetId, + assetName: args.assetName, + ); }, ); } class DriftActivitiesRouteArgs { - const DriftActivitiesRouteArgs({this.key, required this.album}); + const DriftActivitiesRouteArgs({ + this.key, + required this.album, + this.assetId, + this.assetName, + }); final Key? key; final RemoteAlbum album; + final String? assetId; + + final String? assetName; + @override String toString() { - return 'DriftActivitiesRouteArgs{key: $key, album: $album}'; + return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}'; } } @@ -1852,22 +1873,6 @@ class HeaderSettingsRoute extends PageRouteInfo { ); } -/// generated route for -/// [ImmichUIShowcasePage] -class ImmichUIShowcaseRoute extends PageRouteInfo { - const ImmichUIShowcaseRoute({List? children}) - : super(ImmichUIShowcaseRoute.name, initialChildren: children); - - static const String name = 'ImmichUIShowcaseRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const ImmichUIShowcasePage(); - }, - ); -} - /// generated route for /// [LibraryPage] class LibraryRoute extends PageRouteInfo { @@ -2438,6 +2443,44 @@ class PlacesCollectionRouteArgs { } } +/// generated route for +/// [ProfilePictureCropPage] +class ProfilePictureCropRoute + extends PageRouteInfo { + ProfilePictureCropRoute({ + Key? key, + required BaseAsset asset, + List? children, + }) : super( + ProfilePictureCropRoute.name, + args: ProfilePictureCropRouteArgs(key: key, asset: asset), + initialChildren: children, + ); + + static const String name = 'ProfilePictureCropRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return ProfilePictureCropPage(key: args.key, asset: args.asset); + }, + ); +} + +class ProfilePictureCropRouteArgs { + const ProfilePictureCropRouteArgs({this.key, required this.asset}); + + final Key? key; + + final BaseAsset asset; + + @override + String toString() { + return 'ProfilePictureCropRouteArgs{key: $key, asset: $asset}'; + } +} + /// generated route for /// [RecentlyTakenPage] class RecentlyTakenRoute extends PageRouteInfo { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 3d3ef1494c..c435bf9d79 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -240,6 +240,12 @@ class ActionService { return _downloadRepository.downloadAllAssets(assets); } + Future setAlbumCover(String albumId, String assetId) async { + final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, thumbnailAssetId: assetId); + await _remoteAlbumRepository.update(updatedAlbum); + return true; + } + Future _deleteLocalAssets(List localIds) async { final deletedIds = await _assetMediaRepository.deleteAll(localIds); if (deletedIds.isEmpty) { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 1a714b6f40..bafe780647 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -35,6 +35,7 @@ class ApiService implements Authentication { late ViewsApi viewApi; late MemoriesApi memoriesApi; late SessionsApi sessionsApi; + late TagsApi tagsApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -74,6 +75,7 @@ class ApiService implements Authentication { viewApi = ViewsApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); + tagsApi = TagsApi(_apiClient); } Future _setUserAgentHeader() async { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 4e740ebfe5..db4fc9965a 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -35,6 +35,7 @@ enum AppSettingsEnum { loopVideo(StoreKey.loopVideo, "loopVideo", true), loadOriginalVideo(StoreKey.loadOriginalVideo, "loadOriginalVideo", false), autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), + tapToNavigate(StoreKey.tapToNavigate, "tapToNavigate", false), mapThemeMode(StoreKey.mapThemeMode, null, 0), mapShowFavoriteOnly(StoreKey.mapShowFavoriteOnly, null, false), mapIncludeArchived(StoreKey.mapIncludeArchived, null, false), diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 0803cfcdf0..9d2bdbe4a0 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart' as beta_asset_service; import 'package:immich_mobile/domain/services/memory.service.dart'; +import 'package:immich_mobile/domain/services/people.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -13,6 +14,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -33,6 +35,7 @@ final deepLinkServiceProvider = Provider( ref.watch(beta_asset_provider.assetServiceProvider), ref.watch(remoteAlbumServiceProvider), ref.watch(driftMemoryServiceProvider), + ref.watch(driftPeopleServiceProvider), ref.watch(currentUserProvider), ), ); @@ -49,7 +52,8 @@ class DeepLinkService { final TimelineFactory _betaTimelineFactory; final beta_asset_service.AssetService _betaAssetService; final RemoteAlbumService _betaRemoteAlbumService; - final DriftMemoryService _betaMemoryServiceProvider; + final DriftMemoryService _betaMemoryService; + final DriftPeopleService _betaPeopleService; final UserDto? _currentUser; @@ -62,7 +66,8 @@ class DeepLinkService { this._betaTimelineFactory, this._betaAssetService, this._betaRemoteAlbumService, - this._betaMemoryServiceProvider, + this._betaMemoryService, + this._betaPeopleService, this._currentUser, ); @@ -84,6 +89,7 @@ class DeepLinkService { "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), + "people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''), "activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''), _ => null, }; @@ -106,6 +112,7 @@ class DeepLinkService { const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; final assetRegex = RegExp('/photos/($uuidRegex)'); final albumRegex = RegExp('/albums/($uuidRegex)'); + final peopleRegex = RegExp('/people/($uuidRegex)'); PageRouteInfo? deepLinkRoute; if (assetRegex.hasMatch(path)) { @@ -114,6 +121,9 @@ class DeepLinkService { } else if (albumRegex.hasMatch(path)) { final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; deepLinkRoute = await _buildAlbumDeepLink(albumId); + } else if (peopleRegex.hasMatch(path)) { + final peopleId = peopleRegex.firstMatch(path)?.group(1) ?? ''; + deepLinkRoute = await _buildPeopleDeepLink(peopleId); } else if (path == "/memory") { deepLinkRoute = await _buildMemoryDeepLink(null); } @@ -136,9 +146,9 @@ class DeepLinkService { return null; } - memories = await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id); + memories = await _betaMemoryService.getMemoryLane(_currentUser.id); } else { - final memory = await _betaMemoryServiceProvider.get(memoryId); + final memory = await _betaMemoryService.get(memoryId); if (memory != null) { memories = [memory]; } @@ -225,4 +235,18 @@ class DeepLinkService { return DriftActivitiesRoute(album: album); } + + Future _buildPeopleDeepLink(String personId) async { + if (Store.isBetaTimelineEnabled == false) { + return null; + } + + final person = await _betaPeopleService.get(personId); + + if (person == null) { + return null; + } + + return DriftPersonRoute(person: person); + } } diff --git a/mobile/lib/services/shared_link.service.dart b/mobile/lib/services/shared_link.service.dart index 25151c234f..46e83f0fc4 100644 --- a/mobile/lib/services/shared_link.service.dart +++ b/mobile/lib/services/shared_link.service.dart @@ -37,6 +37,7 @@ class SharedLinkService { required bool allowUpload, String? description, String? password, + String? slug, String? albumId, List? assetIds, DateTime? expiresAt, @@ -54,6 +55,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, ); } else if (assetIds != null) { dto = SharedLinkCreateDto( @@ -64,6 +66,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, assetIds: assetIds, ); } @@ -88,6 +91,7 @@ class SharedLinkService { bool? changeExpiry = false, String? description, String? password, + String? slug, DateTime? expiresAt, }) async { try { @@ -100,6 +104,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, changeExpiryTime: changeExpiry, ), ); diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart index a633a04d7f..3837d6337c 100644 --- a/mobile/lib/theme/theme_data.dart +++ b/mobile/lib/theme/theme_data.dart @@ -73,7 +73,9 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale ), navigationBarTheme: NavigationBarThemeData( backgroundColor: isDark ? colorScheme.surfaceContainer : colorScheme.surface, - labelTextStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + labelTextStyle: const WidgetStatePropertyAll( + TextStyle(fontSize: 14, fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis), + ), ), inputDecorationTheme: InputDecorationTheme( focusedBorder: OutlineInputBorder( diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 1a2883bee7..2e26d8e80d 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -20,9 +20,11 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -42,6 +44,7 @@ class ActionButtonContext { final bool isCasting; final TimelineOrigin timelineOrigin; final ThemeData? originalTheme; + final int selectedCount; const ActionButtonContext({ required this.asset, @@ -56,6 +59,7 @@ class ActionButtonContext { this.isCasting = false, this.timelineOrigin = TimelineOrigin.main, this.originalTheme, + this.selectedCount = 1, }); } @@ -65,7 +69,9 @@ enum ActionButtonType { share, shareLink, cast, + setAlbumCover, similarPhotos, + setProfilePicture, viewInTimeline, download, upload, @@ -134,6 +140,11 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.currentAlbum != null, + ActionButtonType.setAlbumCover => + context.isOwner && // + !context.isInLockedView && // + context.currentAlbum != null && // + context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // !context.isInLockedView && // @@ -146,6 +157,10 @@ enum ActionButtonType { ActionButtonType.similarPhotos => !context.isInLockedView && // context.asset is RemoteAsset, + ActionButtonType.setProfilePicture => + !context.isInLockedView && // + context.asset is RemoteAsset && // + context.isOwner, ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && @@ -213,6 +228,12 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.setAlbumCover => SetAlbumCoverActionButton( + albumId: context.currentAlbum!.id, + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.similarPhotos => SimilarPhotosActionButton( @@ -220,12 +241,17 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.setProfilePicture => SetProfilePictureActionButton( + asset: context.asset, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.openInfo => BaseActionButton( label: 'info'.tr(), iconData: Icons.info_outline, iconColor: context.originalTheme?.iconTheme.color, menuItem: true, - onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.tr(), @@ -251,7 +277,7 @@ enum ActionButtonType { int get kebabMenuGroup => switch (this) { // 0: info ActionButtonType.openInfo => 0, - // 10: move,remove, and delete + // 10: move, remove, and delete ActionButtonType.trash => 10, ActionButtonType.deletePermanent => 10, ActionButtonType.removeFromLockFolder => 10, diff --git a/mobile/lib/utils/image_converter.dart b/mobile/lib/utils/image_converter.dart new file mode 100644 index 0000000000..6711e2bd56 --- /dev/null +++ b/mobile/lib/utils/image_converter.dart @@ -0,0 +1,28 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// Converts a Flutter [Image] widget to a [Uint8List] in PNG format. +/// +/// This function resolves the image stream and converts it to byte data. +/// Returns a [Future] that completes with the image bytes or completes with an error +/// if the conversion fails. +Future imageToUint8List(Image image) async { + final Completer completer = Completer(); + image.image + .resolve(const ImageConfiguration()) + .addListener( + ImageStreamListener((ImageInfo info, bool _) { + info.image.toByteData(format: ImageByteFormat.png).then((byteData) { + if (byteData != null) { + completer.complete(byteData.buffer.asUint8List()); + } else { + completer.completeError('Failed to convert image to bytes'); + } + }); + }, onError: (exception, stackTrace) => completer.completeError(exception)), + ); + return completer.future; +} diff --git a/mobile/lib/widgets/activities/activity_text_field.dart b/mobile/lib/widgets/activities/activity_text_field.dart index a61a284844..d21cdfbc94 100644 --- a/mobile/lib/widgets/activities/activity_text_field.dart +++ b/mobile/lib/widgets/activities/activity_text_field.dart @@ -63,7 +63,7 @@ class ActivityTextField extends HookConsumerWidget { prefixIcon: user != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 15), - child: UserCircleAvatar(user: user, size: 30, radius: 15), + child: UserCircleAvatar(user: user, size: 30), ) : null, suffixIcon: Padding( diff --git a/mobile/lib/widgets/activities/activity_tile.dart b/mobile/lib/widgets/activities/activity_tile.dart index e0eccbff21..ac3b6c95a4 100644 --- a/mobile/lib/widgets/activities/activity_tile.dart +++ b/mobile/lib/widgets/activities/activity_tile.dart @@ -40,7 +40,7 @@ class ActivityTile extends HookConsumerWidget { child: Icon(Icons.thumb_up, color: context.primaryColor), ) : isBottomSheet - ? UserCircleAvatar(user: activity.user, size: 30, radius: 15) + ? UserCircleAvatar(user: activity.user, size: 30) : UserCircleAvatar(user: activity.user), title: _ActivityTitle( userName: activity.user.name, diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart index 5f060833a7..401e4b8e99 100644 --- a/mobile/lib/widgets/activities/comment_bubble.dart +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -41,7 +41,7 @@ class CommentBubble extends ConsumerWidget { // avatar (hidden for own messages) Widget avatar = const SizedBox.shrink(); if (!isOwn) { - avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14); + avatar = UserCircleAvatar(user: activity.user, size: 28); } // Thumbnail with tappable behavior and optional heart overlay diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart index 8913e94136..2025fa7583 100644 --- a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -33,7 +33,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget { itemBuilder: ((context, index) { return Padding( padding: const EdgeInsets.only(right: 4.0), - child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true), + child: UserCircleAvatar(user: sharedUsers[index], size: 36, hasBorder: true), ); }), itemCount: sharedUsers.length, diff --git a/mobile/lib/widgets/asset_viewer/center_play_button.dart b/mobile/lib/widgets/asset_viewer/center_play_button.dart index 26d0a41129..55d8be8095 100644 --- a/mobile/lib/widgets/asset_viewer/center_play_button.dart +++ b/mobile/lib/widgets/asset_viewer/center_play_button.dart @@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget { @override Widget build(BuildContext context) { - return ColoredBox( - color: Colors.transparent, - child: Center( - child: UnconstrainedBox( - child: AnimatedOpacity( - opacity: show ? 1.0 : 0.0, - duration: const Duration(milliseconds: 100), - child: DecoratedBox( - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), - child: IconButton( - iconSize: 32, - padding: const EdgeInsets.all(12.0), - icon: isFinished - ? Icon(Icons.replay, color: iconColor) - : AnimatedPlayPause(color: iconColor, playing: isPlaying), - onPressed: onPressed, - ), + return Center( + child: UnconstrainedBox( + child: AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: DecoratedBox( + decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), + child: IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12.0), + icon: isFinished + ? Icon(Icons.replay, color: iconColor) + : AnimatedPlayPause(color: iconColor, playing: isPlaying), + onPressed: onPressed, ), ), ), 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 58c73a77b8..c330fb4649 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 @@ -52,7 +52,10 @@ class ImmichAppBarDialog extends HookConsumerWidget { child: Stack( alignment: Alignment.centerLeft, children: [ - IconButton(onPressed: () => context.pop(), icon: const Icon(Icons.close, size: 20)), + IconButton( + onPressed: () => context.pop(), + icon: Icon(Icons.close, size: 20, color: context.colorScheme.onSurfaceVariant), + ), Align( alignment: Alignment.center, child: Padding( @@ -153,42 +156,23 @@ class ImmichAppBarDialog extends HookConsumerWidget { percentage = user.quotaUsageInBytes / user.quotaSizeInBytes; } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 3), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 4), - decoration: BoxDecoration(color: context.colorScheme.surface), - child: ListTile( - minLeadingWidth: 50, - leading: Icon(Icons.storage_rounded, color: theme.primaryColor), - title: Text( - "backup_controller_page_server_storage", - style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500), - ).tr(), - isThreeLine: true, - subtitle: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: LinearProgressIndicator( - minHeight: 10.0, - value: percentage, - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 12.0), - child: const Text( - 'backup_controller_page_storage_format', - ).tr(namedArgs: {'used': usedDiskSpace, 'total': totalDiskSpace}), - ), - ], - ), + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge), + LinearProgressIndicator( + minHeight: 10.0, + value: percentage, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), ), - ), + Text( + 'backup_controller_page_storage_format', + style: context.textTheme.bodySmall, + ).tr(namedArgs: {'used': usedDiskSpace, 'total': totalDiskSpace}), + ], ), ); } @@ -275,9 +259,22 @@ class ImmichAppBarDialog extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Container(padding: const EdgeInsets.symmetric(horizontal: 8), child: buildTopRow()), - const AppBarProfileInfoBox(), - buildStorageInformation(), - const AppBarServerInfo(), + Container( + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + margin: const EdgeInsets.only(left: 12, right: 12, bottom: 8), + child: Column( + children: [ + const AppBarProfileInfoBox(), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), + buildStorageInformation(), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), + const AppBarServerInfo(), + ], + ), + ), if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(), buildAppLogButton(), buildFreeUpSpaceButton(), 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 bc1d608b10..a9fdb9a43f 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 @@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); } - final userImage = UserCircleAvatar(radius: 22, size: 44, user: user); + final userImage = UserCircleAvatar(size: 44, user: user, hasBorder: true); if (uploadProfileImageStatus == UploadProfileStatus.loading) { return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20)); @@ -80,50 +80,40 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: context.colorScheme.surface, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(10), topRight: Radius.circular(10)), - ), - child: ListTile( - minLeadingWidth: 50, - leading: GestureDetector( - onTap: pickUserProfileImage, - onLongPress: toggleReadonlyMode, - child: Stack( - clipBehavior: Clip.none, - children: [ - AbsorbPointer(child: buildUserProfileImage()), - if (!isReadonlyModeEnabled) - Positioned( - bottom: -5, - right: -8, - child: Material( - color: context.colorScheme.surfaceContainerHighest, - elevation: 3, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), - ), - ), + return ListTile( + minLeadingWidth: 50, + leading: GestureDetector( + onTap: pickUserProfileImage, + onLongPress: toggleReadonlyMode, + child: Stack( + clipBehavior: Clip.none, + children: [ + AbsorbPointer(child: buildUserProfileImage()), + if (!isReadonlyModeEnabled) + Positioned( + bottom: -5, + right: -8, + child: Material( + color: context.colorScheme.surfaceContainerHighest, + elevation: 3, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), ), - ], - ), - ), - title: Text( - authState.name, - style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor, fontWeight: FontWeight.w500), - ), - subtitle: Text( - authState.userEmail, - style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), + ), + ), + ], ), ), + title: Text( + authState.name, + style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor, fontWeight: FontWeight.w500), + ), + subtitle: Text( + authState.userEmail, + style: context.textTheme.bodySmall?.copyWith(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 a341d6395c..2809505c58 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 @@ -23,8 +23,6 @@ class AppBarServerInfo extends HookConsumerWidget { final bool showVersionWarning = ref.watch(versionWarningPresentProvider(user)); final appInfo = useState({}); - const titleFontSize = 12.0; - const contentFontSize = 11.0; getPackageInfo() async { PackageInfo packageInfo = await PackageInfo.fromPlatform(); @@ -37,189 +35,103 @@ class AppBarServerInfo extends HookConsumerWidget { return null; }, []); + const divider = Divider(thickness: 1); + return Padding( - padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 10.0), - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.surface, - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(10), bottomRight: Radius.circular(10)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (showVersionWarning) ...[ - const Padding(padding: EdgeInsets.symmetric(horizontal: 8.0), child: ServerUpdateNotification()), - const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)), - ], - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Text( - "server_info_box_app_version".tr(), - style: TextStyle( - fontSize: titleFontSize, - color: context.textTheme.labelSmall?.color, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - Expanded( - flex: 0, - child: Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Text( - "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}", - style: TextStyle( - fontSize: contentFontSize, - color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Text( - "server_version".tr(), - style: TextStyle( - fontSize: titleFontSize, - color: context.textTheme.labelSmall?.color, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - Expanded( - flex: 0, - child: Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Text( - serverInfoState.serverVersion.major > 0 - ? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}" - : "--", - style: TextStyle( - fontSize: contentFontSize, - color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Text( - "server_info_box_server_url".tr(), - style: TextStyle( - fontSize: titleFontSize, - color: context.textTheme.labelSmall?.color, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - Expanded( - flex: 0, - child: Container( - width: 200, - padding: const EdgeInsets.only(right: 10.0), - child: Tooltip( - verticalOffset: 0, - decoration: BoxDecoration( - color: context.primaryColor.withValues(alpha: 0.9), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - textStyle: TextStyle( - color: context.isDarkTheme ? Colors.black : Colors.white, - fontWeight: FontWeight.bold, - ), - message: getServerUrl() ?? '--', - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - child: Text( - getServerUrl() ?? '--', - style: TextStyle( - fontSize: contentFontSize, - color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, - overflow: TextOverflow.ellipsis, - ), - textAlign: TextAlign.end, - ), - ), - ), - ), - ], - ), - if (serverInfoState.latestVersion != null) ...[ - const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Row( - children: [ - if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate) - const Padding( - padding: EdgeInsets.only(right: 5.0), - child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12), - ), - Text( - "latest_version".tr(), - style: TextStyle( - fontSize: titleFontSize, - color: context.textTheme.labelSmall?.color, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - Expanded( - flex: 0, - child: Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Text( - serverInfoState.latestVersion!.major > 0 - ? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}" - : "--", - style: TextStyle( - fontSize: contentFontSize, - color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ], - ], + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showVersionWarning) ...[const ServerUpdateNotification(), divider], + _ServerInfoItem( + label: "server_info_box_app_version".tr(), + text: "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}", ), - ), + divider, + _ServerInfoItem( + label: "server_version".tr(), + text: serverInfoState.serverVersion.major > 0 + ? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}" + : "--", + ), + divider, + _ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true), + if (serverInfoState.latestVersion != null) ...[ + divider, + _ServerInfoItem( + label: "latest_version".tr(), + text: serverInfoState.latestVersion!.major > 0 + ? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}" + : "--", + tooltip: true, + icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate + ? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12) + : null, + ), + ], + ], ), ); } } + +class _ServerInfoItem extends StatelessWidget { + final String label; + final String text; + final bool tooltip; + final Icon? icon; + + static const titleFontSize = 12.0; + static const contentFontSize = 11.0; + + const _ServerInfoItem({required this.label, required this.text, this.tooltip = false, this.icon}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (icon != null) ...[icon as Widget, const SizedBox(width: 8)], + Text( + label, + style: TextStyle( + fontSize: titleFontSize, + color: context.textTheme.labelSmall?.color, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _maybeTooltip( + context, + Text( + text, + style: TextStyle( + fontSize: contentFontSize, + color: context.colorScheme.onSurfaceSecondary, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.end, + ), + ), + ), + ], + ); + } + + Widget _maybeTooltip(BuildContext context, Widget child) => tooltip + ? Tooltip( + verticalOffset: 0, + decoration: BoxDecoration( + color: context.primaryColor.withValues(alpha: 0.9), + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + textStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.bold), + message: text, + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: child, + ) + : child; +} diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index b3dc04236c..56b7e91eec 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -51,7 +50,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( label: "logged_in_as".tr(namedArgs: {"user": user.name}), - child: UserCircleAvatar(radius: 17, size: 31, user: user), + child: UserCircleAvatar(size: 32, user: user), ), ), ); @@ -153,11 +152,6 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { actions: [ if (actions != null) ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if (kDebugMode || kProfileMode) - IconButton( - icon: const Icon(Icons.palette_rounded), - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - ), if (isCasting) Padding( padding: const EdgeInsets.only(right: 12), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 95622c1e5a..541b7c28c3 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -46,45 +48,37 @@ class ImmichSliverAppBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return SliverAnimatedOpacity( - duration: Durations.medium1, - opacity: isMultiSelectEnabled ? 0 : 1, - sliver: SliverAppBar( - backgroundColor: context.colorScheme.surface, - surfaceTintColor: context.colorScheme.surfaceTint, - elevation: 0, - scrolledUnderElevation: 1.0, - floating: floating, - pinned: pinned, - snap: snap, - expandedHeight: expandedHeight, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), - automaticallyImplyLeading: false, - centerTitle: false, - title: title ?? const _ImmichLogoWithText(), - actions: [ - if (isCasting && !isReadonlyModeEnabled) - Padding( - padding: const EdgeInsets.only(right: 12), - child: IconButton( - onPressed: () { - showDialog(context: context, builder: (context) => const CastDialog()); - }, + return SliverIgnorePointer( + ignoring: isMultiSelectEnabled, + sliver: SliverAnimatedOpacity( + duration: Durations.medium1, + opacity: isMultiSelectEnabled ? 0 : 1, + sliver: SliverAppBar( + backgroundColor: context.colorScheme.surface, + surfaceTintColor: context.colorScheme.surfaceTint, + elevation: 0, + scrolledUnderElevation: 1.0, + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), + automaticallyImplyLeading: false, + centerTitle: false, + title: title ?? const _ImmichLogoWithText(), + actions: [ + const _SyncStatusIndicator(), + if (isCasting && !isReadonlyModeEnabled) + IconButton( + onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()), icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), ), - ), - const _SyncStatusIndicator(), - if (actions != null) - ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) - IconButton( - icon: const Icon(Icons.palette_rounded), - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - ), - if (showUploadButton && !isReadonlyModeEnabled) - const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), - const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()), - ], + if (actions != null) ...actions!, + if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), + const _ProfileIndicator(), + const SizedBox(width: 8), + ], + ), ), ); } @@ -94,27 +88,14 @@ class _ImmichLogoWithText extends StatelessWidget { const _ImmichLogoWithText(); @override - Widget build(BuildContext context) { - return Builder( - builder: (BuildContext context) { - return Row( - children: [ - Builder( - builder: (context) { - return Padding( - padding: const EdgeInsets.only(top: 3.0), - child: SvgPicture.asset( - context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg', - height: 40, - ), - ); - }, - ), - ], - ); - }, - ); - } + Widget build(BuildContext context) => AnimatedOpacity( + opacity: IconTheme.of(context).opacity ?? 1, + duration: kThemeChangeDuration, + child: SvgPicture.asset( + context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg', + height: 40, + ), + ); } class _ProfileIndicator extends ConsumerWidget { @@ -126,7 +107,7 @@ class _ProfileIndicator extends ConsumerWidget { final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user)); final serverInfoState = ref.watch(serverInfoProvider); - const widgetSize = 30.0; + const widgetSize = 32.0; // TODO: remove this when update Flutter version newer than 3.35.7 final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; @@ -146,27 +127,23 @@ class _ProfileIndicator extends ConsumerWidget { ); } - return InkWell( - onTap: () => showDialog( + return IconButton( + onPressed: () => showDialog( context: context, useRootNavigator: false, barrierDismissible: !isIpad, builder: (ctx) => const ImmichAppBarDialog(), ), onLongPress: () => toggleReadonlyMode(), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Badge( - label: Container( - decoration: BoxDecoration( - color: context.isDarkTheme ? Colors.black : Colors.white, - borderRadius: BorderRadius.circular(widgetSize / 2), - ), - child: Icon( + icon: Badge( + label: _BadgeLabel( + Icon( Icons.info, color: serverInfoState.versionStatus == VersionStatus.error ? context.colorScheme.error : context.primaryColor, size: widgetSize / 2, + semanticLabel: 'new_version_available'.tr(), ), ), backgroundColor: Colors.transparent, @@ -177,7 +154,16 @@ class _ProfileIndicator extends ConsumerWidget { ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( label: "logged_in_as".tr(namedArgs: {"user": user.name}), - child: AbsorbPointer(child: UserCircleAvatar(radius: 17, size: 31, user: user)), + child: AbsorbPointer( + child: Builder( + builder: (context) => UserCircleAvatar( + size: 34, + user: user, + opacity: IconTheme.of(context).opacity ?? 1, + hasBorder: true, + ), + ), + ), ), ), ); @@ -193,10 +179,9 @@ class _BackupIndicator extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final indicatorIcon = _getBackupBadgeIcon(context, ref); - return InkWell( - onTap: () => context.pushRoute(const DriftBackupRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Badge( + return IconButton( + onPressed: () => context.pushRoute(const DriftBackupRoute()), + icon: Badge( label: indicatorIcon, backgroundColor: Colors.transparent, alignment: Alignment.bottomRight, @@ -278,12 +263,14 @@ class _BadgeLabel extends StatelessWidget { @override Widget build(BuildContext context) { + final opacity = IconTheme.of(context).opacity ?? 1; + return Container( width: _kBadgeWidgetSize / 2, height: _kBadgeWidgetSize / 2, decoration: BoxDecoration( - color: backgroundColor ?? context.colorScheme.surfaceContainer, - border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)), + color: (backgroundColor ?? context.colorScheme.surfaceContainer).withValues(alpha: opacity), + border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3 * opacity)), borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2), ), child: indicator, @@ -346,23 +333,30 @@ class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> with return const SizedBox.shrink(); } - return AnimatedBuilder( - animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), - builder: (context, child) { - return Padding( - padding: EdgeInsets.only(right: isSyncing ? 16 : 0), - child: Transform.scale( - scale: isSyncing ? 1.0 : _dismissalAnimation.value, - child: Opacity( - opacity: isSyncing ? 1.0 : _dismissalAnimation.value, - child: Transform.rotate( - angle: _rotationAnimation.value * 2 * 3.14159 * -1, // Rotate counter-clockwise - child: Icon(Icons.sync, size: 24, color: context.primaryColor), - ), - ), - ), - ); - }, + return Padding( + padding: const EdgeInsets.all(8), + child: TweenAnimationBuilder( + tween: Tween(end: IconTheme.of(context).opacity ?? 1), + duration: kThemeChangeDuration, + builder: (context, opacity, child) { + return AnimatedBuilder( + animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), + builder: (context, child) { + final dismissalValue = isSyncing ? 1.0 : _dismissalAnimation.value; + return IconTheme( + data: IconTheme.of(context).copyWith(opacity: opacity * dismissalValue), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..scaleByDouble(dismissalValue, dismissalValue, dismissalValue, 1.0) + ..rotateZ(-_rotationAnimation.value * 2 * math.pi), + child: const Icon(Icons.sync), + ), + ); + }, + ); + }, + ), ); } } diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 30eaf4c555..50746f5cbd 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'dart:ui'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -254,22 +254,9 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S ), GestureDetector( onTap: widget.onEditTitle, - child: SizedBox( - width: double.infinity, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Text( - currentAlbum.name, - maxLines: 1, - style: const TextStyle( - color: Colors.white, - fontSize: 36, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)], - ), - ), - ), + child: LayoutBuilder( + builder: (context, constraints) => + _DynamicText(text: currentAlbum.name, maxWidth: constraints.maxWidth), ), ), if (currentAlbum.description.isNotEmpty) @@ -549,3 +536,46 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic ); } } + +class _DynamicText extends StatelessWidget { + final String text; + final double maxWidth; + + const _DynamicText({required this.text, required this.maxWidth}); + + static const _baseTextStyle = TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)], + overflow: TextOverflow.ellipsis, + ); + + int _lineCount(double fontSize) { + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: _baseTextStyle.copyWith(fontSize: fontSize), + ), + maxLines: 3, + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxWidth); + return textPainter.computeLineMetrics().length; + } + + double _fontSize() { + final fontSizes = [44.0, 36.0]; + for (final fontSize in fontSizes) { + final lineCount = _lineCount(fontSize); + if (lineCount == 1) { + return fontSize; + } + } + return 28; + } + + @override + Widget build(BuildContext context) { + return Text(text, style: _baseTextStyle.copyWith(fontSize: _fontSize()), maxLines: 3); + } +} diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart new file mode 100644 index 0000000000..0ab25d14cb --- /dev/null +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; + +class TagPicker extends HookConsumerWidget { + const TagPicker({super.key, required this.onSelect, required this.filter}); + + final Function(Iterable) onSelect; + final Set filter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formFocus = useFocusNode(); + final searchQuery = useState(''); + final tags = ref.watch(tagProvider); + final selectedTagIds = useState>(filter); + final borderRadius = const BorderRadius.all(Radius.circular(10)); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: SearchField( + focusNode: formFocus, + onChanged: (value) => searchQuery.value = value, + onTapOutside: (_) => formFocus.unfocus(), + filled: true, + hintText: 'filter_tags'.tr(), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), + child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), + ), + Expanded( + child: tags.widgetWhen( + onData: (tags) { + final queryResult = tags + .where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + return ListView.builder( + itemCount: queryResult.length, + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + final tag = queryResult[index]; + final isSelected = selectedTagIds.value.any((id) => id == tag.id); + + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Container( + decoration: BoxDecoration( + color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25), + borderRadius: borderRadius, + ), + child: ListTile( + title: Text( + tag.value, + style: context.textTheme.bodyLarge?.copyWith( + color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + onTap: () { + final newSelected = {...selectedTagIds.value}; + if (isSelected) { + newSelected.removeWhere((id) => id == tag.id); + } else { + newSelected.add(tag.id); + } + selectedTagIds.value = newSelected; + onSelect(tags.where((t) => newSelected.contains(t.id))); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 352d686e7c..c6e4f4719e 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -8,49 +8,52 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider. // ignore: must_be_immutable class UserCircleAvatar extends ConsumerWidget { final UserDto user; - double radius; double size; bool hasBorder; + double opacity; - UserCircleAvatar({super.key, this.radius = 22, this.size = 44, this.hasBorder = false, required this.user}); + UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user}); @override Widget build(BuildContext context, WidgetRef ref) { - final userAvatarColor = user.avatarColor.toColor(); + final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity); final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; + final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues( + alpha: opacity, + ); + final textIcon = DefaultTextStyle( - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, - color: userAvatarColor.computeLuminance() > 0.5 ? Colors.black : Colors.white, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: textColor), child: Text(user.name[0].toUpperCase()), ); return Tooltip( message: user.name, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasBorder ? Border.all(color: Colors.grey[500]!, width: 1) : null, - ), - child: CircleAvatar( - backgroundColor: userAvatarColor, - radius: radius, + child: UnconstrainedBox( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: userAvatarColor, + shape: BoxShape.circle, + border: hasBorder ? Border.all(color: userAvatarColor.withValues(alpha: opacity), width: 1.5) : null, + ), child: user.hasProfileImage ? ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(50)), + borderRadius: BorderRadius.all(Radius.circular(size / 2)), child: Image( fit: BoxFit.cover, width: size, height: size, image: RemoteImageProvider(url: profileImageUrl), errorBuilder: (context, error, stackTrace) => textIcon, + color: Colors.white.withValues(alpha: opacity), + colorBlendMode: BlendMode.modulate, ), ) - : textIcon, + : Center(child: textIcon), ), ), ); diff --git a/mobile/lib/widgets/map/asset_marker_icon.dart b/mobile/lib/widgets/map/asset_marker_icon.dart new file mode 100644 index 0000000000..ff6058161b --- /dev/null +++ b/mobile/lib/widgets/map/asset_marker_icon.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class AssetMarkerIcon extends StatelessWidget { + const AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); + + final String id; + final String thumbhash; + + @override + Widget build(BuildContext context) { + final imageUrl = getThumbnailUrlForRemoteId(id); + return LayoutBuilder( + builder: (context, constraints) { + final pinHeight = constraints.maxHeight * 0.14; + final pinWidth = constraints.maxWidth * 0.14; + return SizedOverflowBox( + size: Size(pinWidth, pinHeight), + child: Stack( + // alignment: AlignmentGeometry.center, + children: [ + Positioned( + bottom: 0, + left: constraints.maxWidth * 0.5, + child: CustomPaint( + painter: _PinPainter( + primaryColor: context.colorScheme.onSurface, + secondaryColor: context.colorScheme.surface, + primaryRadius: constraints.maxHeight * 0.06, + secondaryRadius: constraints.maxHeight * 0.038, + ), + child: SizedBox(height: pinHeight, width: pinWidth), + ), + ), + Positioned( + top: constraints.maxHeight * 0.07, + left: constraints.maxWidth * 0.17, + child: CircleAvatar( + radius: constraints.maxHeight * 0.40, + backgroundColor: context.colorScheme.onSurface, + child: CircleAvatar( + radius: constraints.maxHeight * 0.37, + backgroundImage: RemoteImageProvider(url: imageUrl), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _PinPainter extends CustomPainter { + final Color primaryColor; + final Color secondaryColor; + final double primaryRadius; + final double secondaryRadius; + + const _PinPainter({ + required this.primaryColor, + required this.secondaryColor, + required this.primaryRadius, + required this.secondaryRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + Paint primaryBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.fill; + + Paint secondaryBrush = Paint() + ..color = secondaryColor + ..style = PaintingStyle.fill; + + Paint lineBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); + canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); + canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); + // The line is to make the above triangluar path more prominent since it has a slight curve + canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); + } + + Path getTrianglePath(double x, double y) { + final firstEndPoint = Offset(x / 2, y); + final controlPoint = Offset(x / 2, y * 0.3); + final secondEndPoint = Offset(x, 0); + + return Path() + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) + ..lineTo(0, 0); + } + + @override + bool shouldRepaint(_PinPainter old) { + return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; + } +} diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 32d90a28d9..7defb52264 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; -import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// A non-interactive thumbnail of a map in the given coordinates with optional markers @@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); final controller = useRef(null); final styleLoaded = useState(false); - final position = useValueNotifier?>(null); Future onMapCreated(MapLibreMapController mapController) async { controller.value = mapController; styleLoaded.value = false; - if (assetMarkerRemoteId != null) { - // The iOS impl returns wrong toScreenLocation without the delay - Future.delayed( - const Duration(milliseconds: 100), - () async => position.value = await mapController.toScreenLocation(centre), - ); - } onCreated?.call(mapController); } @@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget { child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(15)), child: Stack( - alignment: Alignment.center, + alignment: AlignmentGeometry.topCenter, children: [ style.widgetWhen( onData: (style) => MapLibreMap( - initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom), + initialCameraPosition: CameraPosition(target: centre, zoom: zoom), styleString: style, onMapCreated: onMapCreated, onStyleLoadedCallback: onStyleLoaded, @@ -109,17 +100,16 @@ class MapThumbnail extends HookConsumerWidget { attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null, ), ), - ValueListenableBuilder( - valueListenable: position, - builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null - ? PositionedAssetMarkerIcon( - size: height / 2, - point: value, - assetRemoteId: assetMarkerRemoteId!, - assetThumbhash: assetThumbhash!, - ) - : const SizedBox.shrink(), - ), + if (assetMarkerRemoteId != null && assetThumbhash != null) + Container( + width: width, + height: height / 2, + alignment: Alignment.bottomCenter, + child: SizedBox.square( + dimension: height / 2.5, + child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!), + ), + ), ], ), ), diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 95b127f5b7..b6d7241cf4 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -3,8 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; @@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget { onTap: () => onTap?.call(), child: SizedBox.square( dimension: size, - child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), + child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), ), ), ); } } - -class _AssetMarkerIcon extends StatelessWidget { - const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); - - final String id; - final String thumbhash; - - @override - Widget build(BuildContext context) { - final imageUrl = getThumbnailUrlForRemoteId(id); - return LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Positioned( - bottom: 0, - left: constraints.maxWidth * 0.5, - child: CustomPaint( - painter: _PinPainter( - primaryColor: context.colorScheme.onSurface, - secondaryColor: context.colorScheme.surface, - primaryRadius: constraints.maxHeight * 0.06, - secondaryRadius: constraints.maxHeight * 0.038, - ), - child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14), - ), - ), - Positioned( - top: constraints.maxHeight * 0.07, - left: constraints.maxWidth * 0.17, - child: CircleAvatar( - radius: constraints.maxHeight * 0.40, - backgroundColor: context.colorScheme.onSurface, - child: CircleAvatar( - radius: constraints.maxHeight * 0.37, - backgroundImage: RemoteImageProvider(url: imageUrl), - ), - ), - ), - ], - ); - }, - ); - } -} - -class _PinPainter extends CustomPainter { - final Color primaryColor; - final Color secondaryColor; - final double primaryRadius; - final double secondaryRadius; - - const _PinPainter({ - required this.primaryColor, - required this.secondaryColor, - required this.primaryRadius, - required this.secondaryRadius, - }); - - @override - void paint(Canvas canvas, Size size) { - Paint primaryBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.fill; - - Paint secondaryBrush = Paint() - ..color = secondaryColor - ..style = PaintingStyle.fill; - - Paint lineBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - - canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); - canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); - canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); - // The line is to make the above triangluar path more prominent since it has a slight curve - canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); - } - - Path getTrianglePath(double x, double y) { - final firstEndPoint = Offset(x / 2, y); - final controlPoint = Offset(x / 2, y * 0.3); - final secondEndPoint = Offset(x, 0); - - return Path() - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) - ..lineTo(0, 0); - } - - @override - bool shouldRepaint(_PinPainter old) { - return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; - } -} diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 69be96ed53..f9d3c66767 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -257,6 +257,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -299,6 +300,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -417,6 +419,9 @@ class PhotoView extends StatefulWidget { /// location. final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// A callback when a drag gesture is canceled by the system. + final VoidCallback? onDragCancel; + /// A pointer that will trigger a scale has stopped contacting the screen at a /// particular location. final PhotoViewImageScaleEndCallback? onScaleEnd; @@ -543,7 +548,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final computedOuterSize = widget.customSize ?? constraints.biggest; - final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.black); + final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.transparent); return widget._isCustomChild ? CustomChildWrapper( @@ -564,6 +569,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, @@ -596,6 +602,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index af5b9a7ce7..aa33d18403 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -284,6 +284,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -321,6 +322,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -367,6 +369,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -397,6 +400,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -454,9 +458,12 @@ class PhotoViewGalleryPageOptions { /// Mirror to [PhotoView.onDragDown] final PhotoViewImageDragEndCallback? onDragEnd; - /// Mirror to [PhotoView.onDraUpdate] + /// Mirror to [PhotoView.onDragUpdate] final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// Mirror to [PhotoView.onDragCancel] + final VoidCallback? onDragCancel; + /// Mirror to [PhotoView.onTapDown] final PhotoViewImageTapDownCallback? onTapDown; diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index d21b49f020..72c4766c45 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -36,6 +36,7 @@ class PhotoViewCore extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.gestureDetectorBehavior, @@ -62,6 +63,7 @@ class PhotoViewCore extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -100,6 +102,7 @@ class PhotoViewCore extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageLongPressStartCallback? onLongPressStart; @@ -386,6 +389,7 @@ class PhotoViewCoreState extends State onDragUpdate: widget.onDragUpdate != null ? (details) => widget.onDragUpdate!(context, details, widget.controller.value) : null, + onDragCancel: widget.onDragCancel, hitDetector: this, onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null, onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null, diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 0d2f6fa457..6cbcec8d82 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -16,6 +16,7 @@ class PhotoViewGestureDetector extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onLongPressStart, this.child, this.onTapUp, @@ -34,6 +35,7 @@ class PhotoViewGestureDetector extends StatelessWidget { final GestureDragEndCallback? onDragEnd; final GestureDragStartCallback? onDragStart; final GestureDragUpdateCallback? onDragUpdate; + final GestureDragCancelCallback? onDragCancel; final GestureTapUpCallback? onTapUp; final GestureTapDownCallback? onTapDown; @@ -73,7 +75,8 @@ class PhotoViewGestureDetector extends StatelessWidget { instance ..onStart = onDragStart ..onUpdate = onDragUpdate - ..onEnd = onDragEnd; + ..onEnd = onDragEnd + ..onCancel = onDragCancel; }, ); } diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index cd70745703..ee18668f52 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -28,6 +28,7 @@ class ImageWrapper extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.outerSize, @@ -62,6 +63,7 @@ class ImageWrapper extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -203,6 +205,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: widget.outerSize, @@ -233,6 +236,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, gestureDetectorBehavior: widget.gestureDetectorBehavior, @@ -281,6 +285,7 @@ class CustomChildWrapper extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, required this.outerSize, @@ -313,6 +318,7 @@ class CustomChildWrapper extends StatelessWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -348,6 +354,7 @@ class CustomChildWrapper extends StatelessWidget { onDragStart: onDragStart, onDragEnd: onDragEnd, onDragUpdate: onDragUpdate, + onDragCancel: onDragCancel, onScaleEnd: onScaleEnd, onLongPressStart: onLongPressStart, gestureDetectorBehavior: gestureDetectorBehavior, diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index 5d82630fc6..2d5c9f06eb 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -24,11 +25,12 @@ class LayoutSettings extends HookConsumerWidget { title: "asset_list_layout_sub_title".t(context: context), icon: Icons.view_module_outlined, ), - SettingsSwitchListTile( - valueNotifier: useDynamicLayout, - title: "asset_list_layout_settings_dynamic_layout_title".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), - ), + if (!Store.isBetaTimelineEnabled) + SettingsSwitchListTile( + valueNotifier: useDynamicLayout, + title: "asset_list_layout_settings_dynamic_layout_title".t(context: context), + onChanged: (_) => ref.invalidate(appSettingsServiceProvider), + ), SettingsSliderListTile( valueNotifier: tilesPerRow, text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}), diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index 5dea38d85e..1555790ff9 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'video_viewer_settings.dart'; @@ -8,7 +9,11 @@ class AssetViewerSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final assetViewerSetting = [const ImageViewerQualitySetting(), const VideoViewerSettings()]; + final assetViewerSetting = [ + const ImageViewerQualitySetting(), + const ImageViewerTapToNavigateSetting(), + const VideoViewerSettings(), + ]; return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true); } diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart new file mode 100644 index 0000000000..759162cab8 --- /dev/null +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart @@ -0,0 +1,30 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.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'; + +class ImageViewerTapToNavigateSetting extends HookConsumerWidget { + const ImageViewerTapToNavigateSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSubTitle(title: "setting_image_navigation_title".tr()), + SettingsSwitchListTile( + valueNotifier: tapToNavigate, + title: "setting_image_navigation_enable_title".tr(), + subtitle: "setting_image_navigation_enable_subtitle".tr(), + onChanged: (_) => ref.invalidate(appSettingsServiceProvider), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart index c3bb64faf6..d7e547054e 100644 --- a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart +++ b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart @@ -1,9 +1,8 @@ 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/generated/intl_keys.g.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/routing/router.dart'; class CustomProxyHeaderSettings extends StatelessWidget { @@ -15,11 +14,11 @@ class CustomProxyHeaderSettings extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 20), dense: true, title: Text( - IntlKeys.advanced_settings_proxy_headers_title.tr(), + context.t.advanced_settings_proxy_headers_title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), ), subtitle: Text( - IntlKeys.advanced_settings_proxy_headers_subtitle.tr(), + context.t.advanced_settings_proxy_headers_subtitle, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), onTap: () => context.pushRoute(const HeaderSettingsRoute()), diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart index da5ecab684..ba21acf49c 100644 --- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -54,7 +54,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { saveEndpointList(); } - Widget proxyDecorator(Widget child, int index, Animation animation) { + Widget proxyDecorator(Widget child, int _, Animation animation) { return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index cbd6e1f077..19da80b833 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -78,7 +78,10 @@ class SharedLinkItem extends ConsumerWidget { return; } - Clipboard.setData(ClipboardData(text: "${serverUrl}share/${sharedLink.key}")).then((_) { + final hasSlug = sharedLink.slug?.isNotEmpty == true; + final urlPath = hasSlug ? sharedLink.slug : sharedLink.key; + final basePath = hasSlug ? 's' : 'share'; + Clipboard.setData(ClipboardData(text: "$serverUrl$basePath/$urlPath")).then((_) { context.scaffoldMessenger.showSnackBar( SnackBar( content: Text( diff --git a/mobile/makefile b/mobile/makefile index 5f0a1a9f05..3a0a263687 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -41,7 +41,7 @@ translation: dart run easy_localization:generate -S ../i18n dart run bin/generate_keys.dart dart format lib/generated/codegen_loader.g.dart - dart format lib/generated/intl_keys.g.dart + dart format lib/generated/translations.g.dart analyze: dart analyze --fatal-infos diff --git a/mobile/mise.toml b/mobile/mise.toml index cdafd1cc18..88b8902053 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -16,7 +16,15 @@ sources = [ "infrastructure/**/*.drift", ] outputs = { auto = true } -run = "dart run build_runner build --delete-conflicting-outputs" +run = [ + "dart run build_runner build --delete-conflicting-outputs", + "dart format lib/routing/router.gr.dart", +] + +[tasks."codegen:watch"] +alias = "watch" +description = "Watch and auto-generate dart code" +run = "dart run build_runner watch --delete-conflicting-outputs" [tasks."codegen:pigeon"] alias = "pigeon" @@ -32,13 +40,7 @@ depends = [ [tasks."codegen:translation"] alias = "translation" description = "Generate translations from i18n JSONs" -run = [ - { task = "//i18n:format-fix" }, - { tasks = [ - "i18n:loader", - "i18n:keys", - ] }, -] +run = [{ task = "//:i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }] [tasks."codegen:app-icon"] description = "Generate app icons" @@ -158,10 +160,10 @@ run = [ description = "Generate i18n keys" hide = true sources = ["i18n/en.json"] -outputs = "lib/generated/intl_keys.g.dart" +outputs = "lib/generated/translations.g.dart" run = [ "dart run bin/generate_keys.dart", - "dart format lib/generated/intl_keys.g.dart", + "dart format lib/generated/translations.g.dart", ] [tasks."analyze:dart"] diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index 5245622753..4d0c5c8165 100644 --- a/mobile/openapi/lib/api/download_api.dart +++ b/mobile/openapi/lib/api/download_api.dart @@ -24,17 +24,17 @@ class DownloadApi { /// /// Parameters: /// - /// * [AssetIdsDto] assetIdsDto (required): + /// * [DownloadArchiveDto] downloadArchiveDto (required): /// /// * [String] key: /// /// * [String] slug: - Future downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future downloadArchiveWithHttpInfo(DownloadArchiveDto downloadArchiveDto, { String? key, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/download/archive'; // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = downloadArchiveDto; final queryParams = []; final headerParams = {}; @@ -67,13 +67,13 @@ class DownloadApi { /// /// Parameters: /// - /// * [AssetIdsDto] assetIdsDto (required): + /// * [DownloadArchiveDto] downloadArchiveDto (required): /// /// * [String] key: /// /// * [String] slug: - Future downloadArchive(AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await downloadArchiveWithHttpInfo(assetIdsDto, key: key, slug: slug, ); + Future downloadArchive(DownloadArchiveDto downloadArchiveDto, { String? key, String? slug, }) async { + final response = await downloadArchiveWithHttpInfo(downloadArchiveDto, key: key, slug: slug, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 7f11db76d3..37eeffcf46 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -495,6 +495,77 @@ class SharedLinksApi { return null; } + /// Shared link login + /// + /// Login to a password protected shared link + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [SharedLinkLoginDto] sharedLinkLoginDto (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future sharedLinkLoginWithHttpInfo(SharedLinkLoginDto sharedLinkLoginDto, { String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/shared-links/login'; + + // ignore: prefer_final_locals + Object? postBody = sharedLinkLoginDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Shared link login + /// + /// Login to a password protected shared link + /// + /// Parameters: + /// + /// * [SharedLinkLoginDto] sharedLinkLoginDto (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future sharedLinkLogin(SharedLinkLoginDto sharedLinkLoginDto, { String? key, String? slug, }) async { + final response = await sharedLinkLoginWithHttpInfo(sharedLinkLoginDto, key: key, slug: slug, ); + 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), 'SharedLinkResponseDto',) as SharedLinkResponseDto; + + } + return null; + } + /// Update a shared link /// /// Update an existing shared link by its ID. diff --git a/mobile/openapi/lib/model/download_archive_dto.dart b/mobile/openapi/lib/model/download_archive_dto.dart new file mode 100644 index 0000000000..20e8527f18 --- /dev/null +++ b/mobile/openapi/lib/model/download_archive_dto.dart @@ -0,0 +1,120 @@ +// +// 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 DownloadArchiveDto { + /// Returns a new [DownloadArchiveDto] instance. + DownloadArchiveDto({ + this.assetIds = const [], + this.edited, + }); + + /// Asset IDs + List assetIds; + + /// Download edited asset if available + /// + /// 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? edited; + + @override + bool operator ==(Object other) => identical(this, other) || other is DownloadArchiveDto && + _deepEquality.equals(other.assetIds, assetIds) && + other.edited == edited; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode) + + (edited == null ? 0 : edited!.hashCode); + + @override + String toString() => 'DownloadArchiveDto[assetIds=$assetIds, edited=$edited]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + if (this.edited != null) { + json[r'edited'] = this.edited; + } else { + // json[r'edited'] = null; + } + return json; + } + + /// Returns a new [DownloadArchiveDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DownloadArchiveDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveDto"); + if (value is Map) { + final json = value.cast(); + + return DownloadArchiveDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + edited: mapValueOfType(json, r'edited'), + ); + } + 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 = DownloadArchiveDto.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 = DownloadArchiveDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DownloadArchiveDto-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] = DownloadArchiveDto.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/shared_link_login_dto.dart b/mobile/openapi/lib/model/shared_link_login_dto.dart new file mode 100644 index 0000000000..1ab1bc9349 --- /dev/null +++ b/mobile/openapi/lib/model/shared_link_login_dto.dart @@ -0,0 +1,100 @@ +// +// 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 SharedLinkLoginDto { + /// Returns a new [SharedLinkLoginDto] instance. + SharedLinkLoginDto({ + required this.password, + }); + + /// Shared link password + String password; + + @override + bool operator ==(Object other) => identical(this, other) || other is SharedLinkLoginDto && + other.password == password; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (password.hashCode); + + @override + String toString() => 'SharedLinkLoginDto[password=$password]'; + + Map toJson() { + final json = {}; + json[r'password'] = this.password; + return json; + } + + /// Returns a new [SharedLinkLoginDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SharedLinkLoginDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkLoginDto"); + if (value is Map) { + final json = value.cast(); + + return SharedLinkLoginDto( + password: mapValueOfType(json, r'password')!, + ); + } + 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 = SharedLinkLoginDto.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 = SharedLinkLoginDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SharedLinkLoginDto-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] = SharedLinkLoginDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'password', + }; +} + diff --git a/mobile/packages/ui/.gitignore b/mobile/packages/ui/.gitignore new file mode 100644 index 0000000000..b84f47ac2c --- /dev/null +++ b/mobile/packages/ui/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +build/ + +# Platform-specific files are not needed as this is a Flutter UI package +android/ +ios/ + +# Test cache and generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies + +# Fonts copied by build process +fonts/ \ No newline at end of file diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 9f2a886ab3..909ab65bce 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,5 +1,6 @@ export 'src/components/close_button.dart'; export 'src/components/form.dart'; +export 'src/components/html_text.dart'; export 'src/components/icon_button.dart'; export 'src/components/password_input.dart'; export 'src/components/text_button.dart'; diff --git a/mobile/packages/ui/lib/src/components/html_text.dart b/mobile/packages/ui/lib/src/components/html_text.dart new file mode 100644 index 0000000000..72b54b8da5 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/html_text.dart @@ -0,0 +1,189 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +enum _HtmlTagType { + bold, + link, + unsupported, +} + +class _HtmlTag { + final _HtmlTagType type; + final String tagName; + + const _HtmlTag._({required this.type, required this.tagName}); + + static const unsupported = _HtmlTag._(type: _HtmlTagType.unsupported, tagName: 'unsupported'); + + static _HtmlTag? fromString(dom.Node node) { + final tagName = (node is dom.Element) ? node.localName : null; + if (tagName == null) { + return null; + } + + final tag = tagName.toLowerCase(); + return switch (tag) { + 'b' || 'strong' => _HtmlTag._(type: _HtmlTagType.bold, tagName: tag), + // Convert back to 'link' for handler lookup + 'a' => const _HtmlTag._(type: _HtmlTagType.link, tagName: 'link'), + _ when tag.endsWith('-link') => _HtmlTag._(type: _HtmlTagType.link, tagName: tag), + _ => _HtmlTag.unsupported, + }; + } +} + +/// A widget that renders text with optional HTML-style formatting. +/// +/// Supports the following tags: +/// - `` or `` for bold text +/// - `` or any tag ending with `-link` for tappable links +/// +/// Example: +/// ```dart +/// ImmichHtmlText( +/// 'Refer to docs and other', +/// linkHandlers: { +/// 'link': () => launchUrl(docsUrl), +/// 'other-link': () => launchUrl(otherUrl), +/// }, +/// ) +/// ``` +class ImmichHtmlText extends StatefulWidget { + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final TextOverflow? overflow; + final int? maxLines; + final bool? softWrap; + final Map? linkHandlers; + final TextStyle? linkStyle; + + const ImmichHtmlText( + this.text, { + super.key, + this.style, + this.textAlign, + this.overflow, + this.maxLines, + this.softWrap, + this.linkHandlers, + this.linkStyle, + }); + + @override + State createState() => _ImmichHtmlTextState(); +} + +class _ImmichHtmlTextState extends State { + final _recognizers = []; + dom.DocumentFragment _document = dom.DocumentFragment(); + + @override + void initState() { + super.initState(); + _document = html_parser.parseFragment(_preprocessHtml(widget.text)); + } + + @override + void didUpdateWidget(covariant ImmichHtmlText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + _document = html_parser.parseFragment(_preprocessHtml(widget.text)); + } + } + + /// `` tags are preprocessed to `` tags because `` is a + /// void element in HTML5 and cannot have children. The linkHandlers still use + /// 'link' as the key. + String _preprocessHtml(String html) { + return html + .replaceAllMapped( + RegExp(r'<(link)>(.*?)', caseSensitive: false), + (match) => '${match.group(2)}', + ) + .replaceAllMapped( + RegExp(r'<(link)\s*/>', caseSensitive: false), + (match) => '', + ); + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + List _buildSpans() { + _disposeRecognizers(); + + return _document.nodes.expand((node) => _buildNode(node, null, null)).toList(); + } + + Iterable _buildNode( + dom.Node node, + TextStyle? style, + _HtmlTag? parentTag, + ) sync* { + if (node is dom.Text) { + if (node.text.isEmpty) { + return; + } + + GestureRecognizer? recognizer; + if (parentTag?.type == _HtmlTagType.link) { + final handler = widget.linkHandlers?[parentTag?.tagName]; + if (handler != null) { + recognizer = TapGestureRecognizer()..onTap = handler; + _recognizers.add(recognizer); + } + } + + yield TextSpan(text: node.text, style: style, recognizer: recognizer); + } else if (node is dom.Element) { + final htmlTag = _HtmlTag.fromString(node); + final tagStyle = _styleForTag(htmlTag); + final mergedStyle = style?.merge(tagStyle) ?? tagStyle; + final newParentTag = htmlTag?.type == _HtmlTagType.link ? htmlTag : parentTag; + + for (final child in node.nodes) { + yield* _buildNode(child, mergedStyle, newParentTag); + } + } + } + + TextStyle? _styleForTag(_HtmlTag? tag) { + if (tag == null) { + return null; + } + + return switch (tag.type) { + _HtmlTagType.bold => const TextStyle(fontWeight: FontWeight.bold), + _HtmlTagType.link => widget.linkStyle ?? + TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + _HtmlTagType.unsupported => null, + }; + } + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan(style: widget.style, children: _buildSpans()), + textAlign: widget.textAlign, + overflow: widget.overflow, + maxLines: widget.maxLines, + softWrap: widget.softWrap, + ); + } +} diff --git a/mobile/packages/ui/pubspec.lock b/mobile/packages/ui/pubspec.lock index fa0b425230..c74422dd97 100644 --- a/mobile/packages/ui/pubspec.lock +++ b/mobile/packages/ui/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" characters: dependency: transitive description: @@ -9,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -17,11 +41,72 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -34,15 +119,71 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" vector_math: dependency: transitive description: @@ -51,5 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" sdks: dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/mobile/packages/ui/pubspec.yaml b/mobile/packages/ui/pubspec.yaml index 47b9a9dd8a..d23f34f1a7 100644 --- a/mobile/packages/ui/pubspec.yaml +++ b/mobile/packages/ui/pubspec.yaml @@ -7,6 +7,11 @@ environment: dependencies: flutter: sdk: flutter + html: ^0.15.6 + +dev_dependencies: + flutter_test: + sdk: flutter flutter: uses-material-design: true \ No newline at end of file diff --git a/mobile/packages/ui/showcase/.gitignore b/mobile/packages/ui/showcase/.gitignore new file mode 100644 index 0000000000..b285cd608b --- /dev/null +++ b/mobile/packages/ui/showcase/.gitignore @@ -0,0 +1,11 @@ +# Build artifacts +build/ + +# Test cache and generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies + +# IDE-specific files +.vscode/ \ No newline at end of file diff --git a/mobile/packages/ui/showcase/.metadata b/mobile/packages/ui/showcase/.metadata new file mode 100644 index 0000000000..b95fa4d74e --- /dev/null +++ b/mobile/packages/ui/showcase/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/mobile/packages/ui/showcase/analysis_options.yaml b/mobile/packages/ui/showcase/analysis_options.yaml new file mode 100644 index 0000000000..f9b303465f --- /dev/null +++ b/mobile/packages/ui/showcase/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/mobile/packages/ui/showcase/assets/immich-text-dark.png b/mobile/packages/ui/showcase/assets/immich-text-dark.png new file mode 100644 index 0000000000..215687af8f Binary files /dev/null and b/mobile/packages/ui/showcase/assets/immich-text-dark.png differ diff --git a/mobile/packages/ui/showcase/assets/immich-text-light.png b/mobile/packages/ui/showcase/assets/immich-text-light.png new file mode 100644 index 0000000000..478158d39c Binary files /dev/null and b/mobile/packages/ui/showcase/assets/immich-text-light.png differ diff --git a/mobile/packages/ui/showcase/assets/immich_logo.png b/mobile/packages/ui/showcase/assets/immich_logo.png new file mode 100644 index 0000000000..49fd3ae289 Binary files /dev/null and b/mobile/packages/ui/showcase/assets/immich_logo.png differ diff --git a/mobile/packages/ui/showcase/assets/themes/github_dark.json b/mobile/packages/ui/showcase/assets/themes/github_dark.json new file mode 100644 index 0000000000..bd4801482e --- /dev/null +++ b/mobile/packages/ui/showcase/assets/themes/github_dark.json @@ -0,0 +1,339 @@ +{ + "name": "GitHub Dark", + "settings": [ + { + "settings": { + "foreground": "#e1e4e8", + "background": "#24292e" + } + }, + { + "scope": [ + "comment", + "punctuation.definition.comment", + "string.comment" + ], + "settings": { + "foreground": "#6a737d" + } + }, + { + "scope": [ + "constant", + "entity.name.constant", + "variable.other.constant", + "variable.other.enummember", + "variable.language" + ], + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "entity", + "entity.name" + ], + "settings": { + "foreground": "#b392f0" + } + }, + { + "scope": "variable.parameter.function", + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": "entity.name.tag", + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": "keyword", + "settings": { + "foreground": "#f97583" + } + }, + { + "scope": [ + "storage", + "storage.type" + ], + "settings": { + "foreground": "#f97583" + } + }, + { + "scope": [ + "storage.modifier.package", + "storage.modifier.import", + "storage.type.java" + ], + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": [ + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source" + ], + "settings": { + "foreground": "#9ecbff" + } + }, + { + "scope": "support", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.property-name", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "variable", + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": "variable.other", + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": "invalid.broken", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.deprecated", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.illegal", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.unimplemented", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "message.error", + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": "string variable", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "source.regexp", + "string.regexp" + ], + "settings": { + "foreground": "#dbedff" + } + }, + { + "scope": [ + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition" + ], + "settings": { + "foreground": "#dbedff" + } + }, + { + "scope": "string.regexp constant.character.escape", + "settings": { + "fontStyle": "bold", + "foreground": "#85e89d" + } + }, + { + "scope": "support.constant", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "support.variable", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.module-reference", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "punctuation.definition.list.begin.markdown", + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": [ + "markup.heading", + "markup.heading entity.name" + ], + "settings": { + "fontStyle": "bold", + "foreground": "#79b8ff" + } + }, + { + "scope": "markup.quote", + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": "markup.italic", + "settings": { + "fontStyle": "italic", + "foreground": "#e1e4e8" + } + }, + { + "scope": "markup.bold", + "settings": { + "fontStyle": "bold", + "foreground": "#e1e4e8" + } + }, + { + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "scope": "markup.inline.raw", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted" + ], + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": [ + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted" + ], + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": [ + "markup.changed", + "punctuation.definition.changed" + ], + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": [ + "markup.ignored", + "markup.untracked" + ], + "settings": { + "foreground": "#2f363d" + } + }, + { + "scope": "meta.diff.range", + "settings": { + "fontStyle": "bold", + "foreground": "#b392f0" + } + }, + { + "scope": "meta.diff.header", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.separator", + "settings": { + "fontStyle": "bold", + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.output", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote" + ], + "settings": { + "foreground": "#d1d5da" + } + }, + { + "scope": "brackethighlighter.unmatched", + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": [ + "constant.other.reference.link", + "string.other.link" + ], + "settings": { + "fontStyle": "underline", + "foreground": "#dbedff" + } + } + ] +} diff --git a/mobile/packages/ui/showcase/lib/app_theme.dart b/mobile/packages/ui/showcase/lib/app_theme.dart new file mode 100644 index 0000000000..995bf3c91e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/app_theme.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Light theme colors + static const _primary500 = Color(0xFF4250AF); + static const _primary100 = Color(0xFFD4D6F0); + static const _primary900 = Color(0xFF181E44); + static const _danger500 = Color(0xFFE53E3E); + static const _light50 = Color(0xFFFAFAFA); + static const _light300 = Color(0xFFD4D4D4); + static const _light500 = Color(0xFF737373); + + // Dark theme colors + static const _darkPrimary500 = Color(0xFFACCBFA); + static const _darkPrimary300 = Color(0xFF616D94); + static const _darkDanger500 = Color(0xFFE88080); + static const _darkLight50 = Color(0xFF0A0A0A); + static const _darkLight100 = Color(0xFF171717); + static const _darkLight200 = Color(0xFF262626); + + static ThemeData get lightTheme { + return ThemeData( + colorScheme: const ColorScheme.light( + primary: _primary500, + onPrimary: Colors.white, + primaryContainer: _primary100, + onPrimaryContainer: _primary900, + secondary: _light500, + onSecondary: Colors.white, + error: _danger500, + onError: Colors.white, + surface: _light50, + onSurface: Color(0xFF1A1C1E), + surfaceContainerHighest: Color(0xFFE3E4E8), + outline: Color(0xFFD1D3D9), + outlineVariant: _light300, + ), + useMaterial3: true, + fontFamily: 'GoogleSans', + scaffoldBackgroundColor: _light50, + cardTheme: const CardThemeData( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: _light300, width: 1), + ), + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + foregroundColor: Color(0xFF1A1C1E), + ), + ); + } + + static ThemeData get darkTheme { + return ThemeData( + colorScheme: const ColorScheme.dark( + primary: _darkPrimary500, + onPrimary: Color(0xFF0F1433), + primaryContainer: _darkPrimary300, + onPrimaryContainer: _primary100, + secondary: Color(0xFFC4C6D0), + onSecondary: Color(0xFF2E3042), + error: _darkDanger500, + onError: Color(0xFF0F1433), + surface: _darkLight50, + onSurface: Color(0xFFE3E3E6), + surfaceContainerHighest: _darkLight200, + outline: Color(0xFF8E9099), + outlineVariant: Color(0xFF43464F), + ), + useMaterial3: true, + fontFamily: 'GoogleSans', + scaffoldBackgroundColor: _darkLight50, + cardTheme: const CardThemeData( + elevation: 0, + color: _darkLight100, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: _darkLight200, width: 1), + ), + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: _darkLight50, + surfaceTintColor: Colors.transparent, + foregroundColor: Color(0xFFE3E3E6), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/constants.dart b/mobile/packages/ui/showcase/lib/constants.dart new file mode 100644 index 0000000000..cfca4cfda9 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/constants.dart @@ -0,0 +1,16 @@ +const String appTitle = '@immich/ui'; + +class LayoutConstants { + static const double sidebarWidth = 220.0; + + static const double gridSpacing = 16.0; + static const double gridAspectRatio = 2.5; + + static const double borderRadiusSmall = 6.0; + static const double borderRadiusMedium = 8.0; + static const double borderRadiusLarge = 12.0; + + static const double iconSizeSmall = 16.0; + static const double iconSizeMedium = 18.0; + static const double iconSizeLarge = 20.0; +} diff --git a/mobile/packages/ui/showcase/lib/main.dart b/mobile/packages/ui/showcase/lib/main.dart new file mode 100644 index 0000000000..6cd2df4fe5 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/main.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/app_theme.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/router.dart'; +import 'package:showcase/widgets/example_card.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await initializeCodeHighlighter(); + runApp(const ShowcaseApp()); +} + +class ShowcaseApp extends StatefulWidget { + const ShowcaseApp({super.key}); + + @override + State createState() => _ShowcaseAppState(); +} + +class _ShowcaseAppState extends State { + ThemeMode _themeMode = ThemeMode.light; + late final GoRouter _router; + + @override + void initState() { + super.initState(); + _router = AppRouter.createRouter(_toggleTheme); + } + + void _toggleTheme() { + setState(() { + _themeMode = _themeMode == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: appTitle, + themeMode: _themeMode, + routerConfig: _router, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + debugShowCheckedModeBanner: false, + builder: (context, child) => ImmichThemeProvider( + colorScheme: Theme.of(context).colorScheme, + child: child!, + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart new file mode 100644 index 0000000000..1bae98e0a4 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class CloseButtonPage extends StatelessWidget { + const CloseButtonPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.closeButton.name, + child: ComponentExamples( + title: 'ImmichCloseButton', + subtitle: 'Pre-configured close button for dialogs and sheets.', + examples: [ + ExampleCard( + title: 'Default & Custom', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichCloseButton(onPressed: () {}), + ImmichCloseButton( + variant: ImmichVariant.filled, + onPressed: () {}, + ), + ImmichCloseButton( + color: ImmichColor.secondary, + onPressed: () {}, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart new file mode 100644 index 0000000000..af4c87f40e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextBoldText extends StatelessWidget { + const HtmlTextBoldText({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'This is bold text and strong text.', + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart new file mode 100644 index 0000000000..a764d7173e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextLinks extends StatelessWidget { + const HtmlTextLinks({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'Read the documentation or visit GitHub.', + linkHandlers: { + 'docs-link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))); + }, + 'github-link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))); + }, + }, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart new file mode 100644 index 0000000000..836d949b66 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextNestedTags extends StatelessWidget { + const HtmlTextNestedTags({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'You can combine bold and links together.', + linkHandlers: { + 'link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Nested link clicked!'))); + }, + }, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/form_page.dart b/mobile/packages/ui/showcase/lib/pages/components/form_page.dart new file mode 100644 index 0000000000..14567031de --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/form_page.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class FormPage extends StatefulWidget { + const FormPage({super.key}); + + @override + State createState() => _FormPageState(); +} + +class _FormPageState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + String _result = ''; + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.form.name, + child: ComponentExamples( + title: 'ImmichForm', + subtitle: + 'Form container with built-in validation and submit handling.', + examples: [ + ExampleCard( + title: 'Login Form', + preview: Column( + children: [ + ImmichForm( + submitText: 'Login', + submitIcon: Icons.login, + onSubmit: () async { + await Future.delayed(const Duration(seconds: 1)); + setState(() { + _result = 'Form submitted!'; + }); + }, + child: Column( + spacing: 10, + children: [ + ImmichTextInput( + label: 'Email', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + ImmichPasswordInput( + label: 'Password', + controller: _passwordController, + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + ], + ), + ), + if (_result.isNotEmpty) ...[ + const SizedBox(height: 16), + Text(_result, style: const TextStyle(color: Colors.green)), + ], + ], + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart b/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart new file mode 100644 index 0000000000..64dbc70597 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:showcase/pages/components/examples/html_text_bold_text.dart'; +import 'package:showcase/pages/components/examples/html_text_links.dart'; +import 'package:showcase/pages/components/examples/html_text_nested_tags.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class HtmlTextPage extends StatelessWidget { + const HtmlTextPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.htmlText.name, + child: ComponentExamples( + title: 'ImmichHtmlText', + subtitle: 'Render text with HTML formatting (bold, links).', + examples: [ + ExampleCard( + title: 'Bold Text', + preview: const HtmlTextBoldText(), + code: 'html_text_bold_text.dart', + ), + ExampleCard( + title: 'Links', + preview: const HtmlTextLinks(), + code: 'html_text_links.dart', + ), + ExampleCard( + title: 'Nested Tags', + preview: const HtmlTextNestedTags(), + code: 'html_text_nested_tags.dart', + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart new file mode 100644 index 0000000000..4418b1de4f --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class IconButtonPage extends StatelessWidget { + const IconButtonPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.iconButton.name, + child: ComponentExamples( + title: 'ImmichIconButton', + subtitle: 'Icon-only button with customizable styling.', + examples: [ + ExampleCard( + title: 'Variants & Colors', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichIconButton( + icon: Icons.add, + onPressed: () {}, + variant: ImmichVariant.filled, + ), + ImmichIconButton( + icon: Icons.edit, + onPressed: () {}, + variant: ImmichVariant.ghost, + ), + ImmichIconButton( + icon: Icons.delete, + onPressed: () {}, + color: ImmichColor.secondary, + ), + ImmichIconButton( + icon: Icons.settings, + onPressed: () {}, + disabled: true, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart b/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart new file mode 100644 index 0000000000..772dd7882f --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class PasswordInputPage extends StatelessWidget { + const PasswordInputPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.passwordInput.name, + child: ComponentExamples( + title: 'ImmichPasswordInput', + subtitle: 'Password field with visibility toggle.', + examples: [ + ExampleCard( + title: 'Password Input', + preview: ImmichPasswordInput( + label: 'Password', + hintText: 'Enter your password', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + return null; + }, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart new file mode 100644 index 0000000000..59e5b86294 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class TextButtonPage extends StatefulWidget { + const TextButtonPage({super.key}); + + @override + State createState() => _TextButtonPageState(); +} + +class _TextButtonPageState extends State { + bool _isLoading = false; + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.textButton.name, + child: ComponentExamples( + title: 'ImmichTextButton', + subtitle: + 'A versatile button component with multiple variants and color options.', + examples: [ + ExampleCard( + title: 'Variants', + description: + 'Filled and ghost variants for different visual hierarchy', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Filled', + variant: ImmichVariant.filled, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Ghost', + variant: ImmichVariant.ghost, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Colors', + description: 'Primary and secondary color options', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Primary', + color: ImmichColor.primary, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Secondary', + color: ImmichColor.secondary, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'With Icons', + description: 'Add leading icons', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'With Icon', + icon: Icons.add, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Download', + icon: Icons.download, + variant: ImmichVariant.ghost, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Loading State', + description: 'Shows loading indicator during async operations', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ImmichTextButton( + onPressed: () async { + setState(() => _isLoading = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _isLoading = false); + }, + labelText: _isLoading ? 'Loading...' : 'Click Me', + loading: _isLoading, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Disabled State', + description: 'Buttons can be disabled', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Disabled', + disabled: true, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Disabled Ghost', + variant: ImmichVariant.ghost, + disabled: true, + expanded: false, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart b/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart new file mode 100644 index 0000000000..5a0bfec6cd --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class TextInputPage extends StatefulWidget { + const TextInputPage({super.key}); + + @override + State createState() => _TextInputPageState(); +} + +class _TextInputPageState extends State { + final _controller1 = TextEditingController(); + final _controller2 = TextEditingController(); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.textInput.name, + child: ComponentExamples( + title: 'ImmichTextInput', + subtitle: 'Text field with validation support.', + examples: [ + ExampleCard( + title: 'Basic Usage', + preview: Column( + children: [ + ImmichTextInput( + label: 'Email', + hintText: 'Enter your email', + controller: _controller1, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + ImmichTextInput( + label: 'Username', + controller: _controller2, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Username is required'; + } + if (value.length < 3) { + return 'Username must be at least 3 characters'; + } + return null; + }, + ), + ], + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _controller1.dispose(); + _controller2.dispose(); + super.dispose(); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart b/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart new file mode 100644 index 0000000000..17de02d80a --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class ConstantsPage extends StatefulWidget { + const ConstantsPage({super.key}); + + @override + State createState() => _ConstantsPageState(); +} + +class _ConstantsPageState extends State { + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.constants.name, + child: ComponentExamples( + title: 'Constants', + subtitle: 'Consistent spacing, sizing, and styling constants.', + expand: true, + examples: [ + const ExampleCard( + title: 'Spacing', + description: 'ImmichSpacing (4.0 → 48.0)', + preview: Column( + children: [ + _SpacingBox(label: 'xs', size: ImmichSpacing.xs), + _SpacingBox(label: 'sm', size: ImmichSpacing.sm), + _SpacingBox(label: 'md', size: ImmichSpacing.md), + _SpacingBox(label: 'lg', size: ImmichSpacing.lg), + _SpacingBox(label: 'xl', size: ImmichSpacing.xl), + _SpacingBox(label: 'xxl', size: ImmichSpacing.xxl), + _SpacingBox(label: 'xxxl', size: ImmichSpacing.xxxl), + ], + ), + ), + const ExampleCard( + title: 'Border Radius', + description: 'ImmichRadius (0.0 → 24.0)', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _RadiusBox(label: 'none', radius: ImmichRadius.none), + _RadiusBox(label: 'xs', radius: ImmichRadius.xs), + _RadiusBox(label: 'sm', radius: ImmichRadius.sm), + _RadiusBox(label: 'md', radius: ImmichRadius.md), + _RadiusBox(label: 'lg', radius: ImmichRadius.lg), + _RadiusBox(label: 'xl', radius: ImmichRadius.xl), + _RadiusBox(label: 'xxl', radius: ImmichRadius.xxl), + ], + ), + ), + const ExampleCard( + title: 'Icon Sizes', + description: 'ImmichIconSize (16.0 → 48.0)', + preview: Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.start, + children: [ + _IconSizeBox(label: 'xs', size: ImmichIconSize.xs), + _IconSizeBox(label: 'sm', size: ImmichIconSize.sm), + _IconSizeBox(label: 'md', size: ImmichIconSize.md), + _IconSizeBox(label: 'lg', size: ImmichIconSize.lg), + _IconSizeBox(label: 'xl', size: ImmichIconSize.xl), + _IconSizeBox(label: 'xxl', size: ImmichIconSize.xxl), + ], + ), + ), + const ExampleCard( + title: 'Text Sizes', + description: 'ImmichTextSize (10.0 → 60.0)', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Caption', + style: TextStyle(fontSize: ImmichTextSize.caption), + ), + Text('Label', style: TextStyle(fontSize: ImmichTextSize.label)), + Text('Body', style: TextStyle(fontSize: ImmichTextSize.body)), + Text('H6', style: TextStyle(fontSize: ImmichTextSize.h6)), + Text('H5', style: TextStyle(fontSize: ImmichTextSize.h5)), + Text('H4', style: TextStyle(fontSize: ImmichTextSize.h4)), + Text('H3', style: TextStyle(fontSize: ImmichTextSize.h3)), + Text('H2', style: TextStyle(fontSize: ImmichTextSize.h2)), + Text('H1', style: TextStyle(fontSize: ImmichTextSize.h1)), + ], + ), + ), + const ExampleCard( + title: 'Elevation', + description: 'ImmichElevation (0.0 → 16.0)', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _ElevationBox(label: 'none', elevation: ImmichElevation.none), + _ElevationBox(label: 'xs', elevation: ImmichElevation.xs), + _ElevationBox(label: 'sm', elevation: ImmichElevation.sm), + _ElevationBox(label: 'md', elevation: ImmichElevation.md), + _ElevationBox(label: 'lg', elevation: ImmichElevation.lg), + _ElevationBox(label: 'xl', elevation: ImmichElevation.xl), + _ElevationBox(label: 'xxl', elevation: ImmichElevation.xxl), + ], + ), + ), + const ExampleCard( + title: 'Border Width', + description: 'ImmichBorderWidth (0.5 → 4.0)', + preview: Column( + children: [ + _BorderBox( + label: 'hairline', + borderWidth: ImmichBorderWidth.hairline, + ), + _BorderBox(label: 'base', borderWidth: ImmichBorderWidth.base), + _BorderBox(label: 'md', borderWidth: ImmichBorderWidth.md), + _BorderBox(label: 'lg', borderWidth: ImmichBorderWidth.lg), + _BorderBox(label: 'xl', borderWidth: ImmichBorderWidth.xl), + ], + ), + ), + const ExampleCard( + title: 'Animation Durations', + description: 'ImmichDuration (100ms → 700ms)', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + _AnimatedDurationBox( + label: 'Extra Fast', + duration: ImmichDuration.extraFast, + ), + _AnimatedDurationBox( + label: 'Fast', + duration: ImmichDuration.fast, + ), + _AnimatedDurationBox( + label: 'Normal', + duration: ImmichDuration.normal, + ), + _AnimatedDurationBox( + label: 'Slow', + duration: ImmichDuration.slow, + ), + _AnimatedDurationBox( + label: 'Extra Slow', + duration: ImmichDuration.extraSlow, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SpacingBox extends StatelessWidget { + final String label; + final double size; + + const _SpacingBox({required this.label, required this.size}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text( + label, + style: const TextStyle(fontFamily: 'GoogleSansCode'), + ), + ), + Container( + width: size, + height: 24, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text('${size.toStringAsFixed(1)}px'), + ], + ), + ); + } +} + +class _RadiusBox extends StatelessWidget { + final String label; + final double radius; + + const _RadiusBox({required this.label, required this.radius}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(radius), + ), + ), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + ], + ); + } +} + +class _IconSizeBox extends StatelessWidget { + final String label; + final double size; + + const _IconSizeBox({required this.label, required this.size}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(Icons.palette_rounded, size: size), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + Text( + '${size.toStringAsFixed(0)}px', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ], + ); + } +} + +class _ElevationBox extends StatelessWidget { + final String label; + final double elevation; + + const _ElevationBox({required this.label, required this.elevation}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Material( + elevation: elevation, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Container( + width: 60, + height: 60, + alignment: Alignment.center, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ), + const SizedBox(height: 4), + Text( + elevation.toStringAsFixed(1), + style: const TextStyle(fontSize: 10), + ), + ], + ); + } +} + +class _BorderBox extends StatelessWidget { + final String label; + final double borderWidth; + + const _BorderBox({required this.label, required this.borderWidth}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: const TextStyle(fontFamily: 'GoogleSansCode'), + ), + ), + Expanded( + child: Container( + height: 40, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: borderWidth, + ), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ), + const SizedBox(width: 8), + Text('${borderWidth.toStringAsFixed(1)}px'), + ], + ), + ); + } +} + +class _AnimatedDurationBox extends StatefulWidget { + final String label; + final Duration duration; + + const _AnimatedDurationBox({required this.label, required this.duration}); + + @override + State<_AnimatedDurationBox> createState() => _AnimatedDurationBoxState(); +} + +class _AnimatedDurationBoxState extends State<_AnimatedDurationBox> { + bool _atEnd = false; + bool _isAnimating = false; + + void _playAnimation() async { + if (_isAnimating) return; + setState(() => _isAnimating = true); + setState(() => _atEnd = true); + await Future.delayed(widget.duration); + if (!mounted) return; + setState(() => _atEnd = false); + await Future.delayed(widget.duration); + if (!mounted) return; + setState(() => _isAnimating = false); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + SizedBox( + width: 90, + child: Text( + widget.label, + style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 12), + ), + ), + Expanded( + child: Container( + height: 32, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: AnimatedAlign( + duration: widget.duration, + curve: Curves.easeInOut, + alignment: _atEnd ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 60, + height: 28, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text( + '${widget.duration.inMilliseconds}ms', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: _isAnimating ? null : _playAnimation, + icon: Icon( + Icons.play_arrow_rounded, + color: _isAnimating ? colorScheme.outline : colorScheme.primary, + ), + iconSize: 24, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/home_page.dart b/mobile/packages/ui/showcase/lib/pages/home_page.dart new file mode 100644 index 0000000000..de7af6c26b --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/home_page.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/routes.dart'; + +class HomePage extends StatelessWidget { + final VoidCallback onThemeToggle; + + const HomePage({super.key, required this.onThemeToggle}); + + @override + Widget build(BuildContext context) { + return Title( + title: appTitle, + color: Theme.of(context).colorScheme.primary, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + children: [ + Text( + appTitle, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + 'A collection of Flutter components that are shared across all Immich projects', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + height: 1.5, + ), + ), + const SizedBox(height: 48), + ...routesByCategory.entries.map((entry) { + if (entry.key == AppRouteCategory.root) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.key.displayName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: LayoutConstants.gridSpacing, + mainAxisSpacing: LayoutConstants.gridSpacing, + childAspectRatio: LayoutConstants.gridAspectRatio, + ), + itemCount: entry.value.length, + itemBuilder: (context, index) { + return _ComponentCard(route: entry.value[index]); + }, + ), + const SizedBox(height: 48), + ], + ); + }), + ], + ), + ); + } +} + +class _ComponentCard extends StatelessWidget { + final AppRoute route; + + const _ComponentCard({required this.route}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => context.go(route.path), + borderRadius: const BorderRadius.all(Radius.circular(LayoutConstants.borderRadiusLarge)), + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(route.icon, size: 32, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text( + route.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + + const SizedBox(height: 8), + Text( + route.description, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant, height: 1.4), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/router.dart b/mobile/packages/ui/showcase/lib/router.dart new file mode 100644 index 0000000000..014de44fd8 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/router.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/pages/components/close_button_page.dart'; +import 'package:showcase/pages/components/form_page.dart'; +import 'package:showcase/pages/components/html_text_page.dart'; +import 'package:showcase/pages/components/icon_button_page.dart'; +import 'package:showcase/pages/components/password_input_page.dart'; +import 'package:showcase/pages/components/text_button_page.dart'; +import 'package:showcase/pages/components/text_input_page.dart'; +import 'package:showcase/pages/design_system/constants_page.dart'; +import 'package:showcase/pages/home_page.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/shell_layout.dart'; + +class AppRouter { + static GoRouter createRouter(VoidCallback onThemeToggle) { + return GoRouter( + initialLocation: AppRoute.home.path, + routes: [ + ShellRoute( + builder: (context, state, child) => + ShellLayout(onThemeToggle: onThemeToggle, child: child), + routes: AppRoute.values + .map( + (route) => GoRoute( + path: route.path, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: switch (route) { + AppRoute.home => HomePage(onThemeToggle: onThemeToggle), + AppRoute.textButton => const TextButtonPage(), + AppRoute.iconButton => const IconButtonPage(), + AppRoute.closeButton => const CloseButtonPage(), + AppRoute.textInput => const TextInputPage(), + AppRoute.passwordInput => const PasswordInputPage(), + AppRoute.form => const FormPage(), + AppRoute.htmlText => const HtmlTextPage(), + AppRoute.constants => const ConstantsPage(), + }, + ), + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/routes.dart b/mobile/packages/ui/showcase/lib/routes.dart new file mode 100644 index 0000000000..a39fb7bc34 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/routes.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +enum AppRouteCategory { + root(''), + forms('Forms'), + buttons('Buttons'), + designSystem('Design System'); + + final String displayName; + const AppRouteCategory(this.displayName); +} + +enum AppRoute { + home( + name: 'Home', + description: 'Home page', + path: '/', + category: AppRouteCategory.root, + icon: Icons.home_outlined, + ), + textButton( + name: 'Text Button', + description: 'Versatile button with filled and ghost variants', + path: '/text-button', + category: AppRouteCategory.buttons, + icon: Icons.smart_button_rounded, + ), + iconButton( + name: 'Icon Button', + description: 'Icon-only button with customizable styling', + path: '/icon-button', + category: AppRouteCategory.buttons, + icon: Icons.radio_button_unchecked_rounded, + ), + closeButton( + name: 'Close Button', + description: 'Pre-configured close button for dialogs', + path: '/close-button', + category: AppRouteCategory.buttons, + icon: Icons.close_rounded, + ), + textInput( + name: 'Text Input', + description: 'Text field with validation support', + path: '/text-input', + category: AppRouteCategory.forms, + icon: Icons.text_fields_outlined, + ), + passwordInput( + name: 'Password Input', + description: 'Password field with visibility toggle', + path: '/password-input', + category: AppRouteCategory.forms, + icon: Icons.password_outlined, + ), + form( + name: 'Form', + description: 'Form container with built-in validation', + path: '/form', + category: AppRouteCategory.forms, + icon: Icons.description_outlined, + ), + htmlText( + name: 'Html Text', + description: 'Render text with HTML formatting', + path: '/html-text', + category: AppRouteCategory.forms, + icon: Icons.code_rounded, + ), + constants( + name: 'Constants', + description: 'Spacing, colors, typography, and more', + path: '/constants', + category: AppRouteCategory.designSystem, + icon: Icons.palette_outlined, + ); + + final String name; + final String description; + final String path; + final AppRouteCategory category; + final IconData icon; + + const AppRoute({ + required this.name, + required this.description, + required this.path, + required this.category, + required this.icon, + }); +} + +final routesByCategory = AppRoute.values + .fold>>({}, (map, route) { + map.putIfAbsent(route.category, () => []).add(route); + return map; + }); diff --git a/mobile/packages/ui/showcase/lib/widgets/component_examples.dart b/mobile/packages/ui/showcase/lib/widgets/component_examples.dart new file mode 100644 index 0000000000..21e6516079 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/component_examples.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class ComponentExamples extends StatelessWidget { + final String title; + final String? subtitle; + final List examples; + final bool expand; + + const ComponentExamples({ + super.key, + required this.title, + this.subtitle, + required this.examples, + this.expand = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 24, 24, 24), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _PageHeader(title: title, subtitle: subtitle), + ), + const SliverPadding(padding: EdgeInsets.only(top: 24)), + if (expand) + SliverList.builder( + itemCount: examples.length, + itemBuilder: (context, index) => examples[index], + ) + else + SliverLayoutBuilder( + builder: (context, constraints) { + return SliverList.builder( + itemCount: examples.length, + itemBuilder: (context, index) => Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.crossAxisExtent * 0.6, + maxWidth: constraints.crossAxisExtent, + ), + child: IntrinsicWidth(child: examples[index]), + ), + ), + ); + }, + ), + ], + ), + ); + } +} + +class _PageHeader extends StatelessWidget { + final String title; + final String? subtitle; + + const _PageHeader({required this.title, this.subtitle}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.bold), + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/example_card.dart b/mobile/packages/ui/showcase/lib/widgets/example_card.dart new file mode 100644 index 0000000000..fea561afb6 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/example_card.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:showcase/constants.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +late final Highlighter _codeHighlighter; + +Future initializeCodeHighlighter() async { + await Highlighter.initialize(['dart']); + final darkTheme = await HighlighterTheme.loadFromAssets([ + 'assets/themes/github_dark.json', + ], const TextStyle(color: Color(0xFFe1e4e8))); + + _codeHighlighter = Highlighter(language: 'dart', theme: darkTheme); +} + +class ExampleCard extends StatefulWidget { + final String title; + final String? description; + final Widget preview; + final String? code; + + const ExampleCard({ + super.key, + required this.title, + this.description, + required this.preview, + this.code, + }); + + @override + State createState() => _ExampleCardState(); +} + +class _ExampleCardState extends State { + bool _showPreview = true; + String? code; + + @override + void initState() { + super.initState(); + if (widget.code != null) { + rootBundle + .loadString('lib/pages/components/examples/${widget.code!}') + .then((value) { + setState(() { + code = value; + }); + }); + } + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + if (widget.description != null) + Text( + widget.description!, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (code != null) ...[ + const SizedBox(width: 16), + Row( + children: [ + _ToggleButton( + icon: Icons.visibility_rounded, + label: 'Preview', + isSelected: _showPreview, + onTap: () => setState(() => _showPreview = true), + ), + const SizedBox(width: 8), + _ToggleButton( + icon: Icons.code_rounded, + label: 'Code', + isSelected: !_showPreview, + onTap: () => setState(() => _showPreview = false), + ), + ], + ), + ], + ], + ), + ), + const Divider(height: 1), + if (_showPreview) + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox(width: double.infinity, child: widget.preview), + ) + else + Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Color(0xFF24292e), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular( + LayoutConstants.borderRadiusMedium, + ), + bottomRight: Radius.circular( + LayoutConstants.borderRadiusMedium, + ), + ), + ), + child: _CodeCard(code: code!), + ), + ], + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(24)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ); + } +} + +class _CodeCard extends StatelessWidget { + final String code; + + const _CodeCard({required this.code}); + + @override + Widget build(BuildContext context) { + final lines = code.split('\n'); + final lineNumberColor = Colors.white.withValues(alpha: 0.4); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate( + lines.length, + (index) => SizedBox( + height: 20, + child: Text( + '${index + 1}', + style: TextStyle( + fontFamily: 'GoogleSansCode', + fontSize: 13, + color: lineNumberColor, + height: 1.5, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + SelectableText.rich( + _codeHighlighter.highlight(code), + style: const TextStyle( + fontFamily: 'GoogleSansCode', + fontSize: 13, + height: 1.54, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/page_title.dart b/mobile/packages/ui/showcase/lib/widgets/page_title.dart new file mode 100644 index 0000000000..eae3bf6ffb --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/page_title.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class PageTitle extends StatelessWidget { + final String title; + final Widget child; + + const PageTitle({super.key, required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Title( + title: '$title | @immich/ui', + color: Theme.of(context).colorScheme.primary, + child: child, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart b/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart new file mode 100644 index 0000000000..8bcb687e75 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/widgets/sidebar_navigation.dart'; + +class ShellLayout extends StatelessWidget { + final Widget child; + final VoidCallback onThemeToggle; + + const ShellLayout({ + super.key, + required this.child, + required this.onThemeToggle, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/immich_logo.png', height: 32, width: 32), + const SizedBox(width: 8), + Image.asset( + isDark + ? 'assets/immich-text-dark.png' + : 'assets/immich-text-light.png', + height: 24, + filterQuality: FilterQuality.none, + isAntiAlias: true, + ), + ], + ), + actions: [ + IconButton( + icon: Icon( + isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, + size: LayoutConstants.iconSizeLarge, + ), + onPressed: onThemeToggle, + tooltip: 'Toggle theme', + ), + ], + shape: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), + ), + ), + body: Row( + children: [ + const SidebarNavigation(), + const VerticalDivider(), + Expanded(child: child), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart b/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart new file mode 100644 index 0000000000..10eba170e6 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/routes.dart'; + +class SidebarNavigation extends StatelessWidget { + const SidebarNavigation({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: LayoutConstants.sidebarWidth, + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + children: [ + ...routesByCategory.entries.expand((entry) { + final category = entry.key; + final routes = entry.value; + return [ + if (category != AppRouteCategory.root) _CategoryHeader(category), + ...routes.map((route) => _NavItem(route)), + const SizedBox(height: 24), + ]; + }), + ], + ), + ); + } +} + +class _CategoryHeader extends StatelessWidget { + final AppRouteCategory category; + + const _CategoryHeader(this.category); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8), + child: Text( + category.displayName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class _NavItem extends StatelessWidget { + final AppRoute route; + + const _NavItem(this.route); + + @override + Widget build(BuildContext context) { + final currentRoute = GoRouterState.of(context).uri.toString(); + final isSelected = currentRoute == route.path; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + context.go(route.path); + }, + borderRadius: BorderRadius.circular( + LayoutConstants.borderRadiusMedium, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? (isDark + ? Colors.white.withValues(alpha: 0.1) + : Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.5)) + : Colors.transparent, + borderRadius: BorderRadius.circular( + LayoutConstants.borderRadiusMedium, + ), + ), + child: Row( + children: [ + Icon( + route.icon, + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + route.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/pubspec.lock b/mobile/packages/ui/showcase/pubspec.lock new file mode 100644 index 0000000000..b0725051d3 --- /dev/null +++ b/mobile/packages/ui/showcase/pubspec.lock @@ -0,0 +1,393 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a + url: "https://pub.dev" + source: hosted + version: "17.0.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + immich_ui: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.0" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" + url: "https://pub.dev" + source: hosted + version: "0.5.5" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_clipboard: + dependency: transitive + description: + name: super_clipboard + sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + syntax_highlight: + dependency: "direct main" + description: + name: syntax_highlight + sha256: "4d3ba40658cadba6ba55d697f29f00b43538ebb6eb4a0ca0e895c568eaced138" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/mobile/packages/ui/showcase/pubspec.yaml b/mobile/packages/ui/showcase/pubspec.yaml new file mode 100644 index 0000000000..e45ce07e66 --- /dev/null +++ b/mobile/packages/ui/showcase/pubspec.yaml @@ -0,0 +1,47 @@ +name: showcase +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + immich_ui: + path: ../ + go_router: ^17.0.1 + syntax_highlight: ^0.5.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/ + - assets/themes/ + - lib/pages/components/examples/ + + fonts: + - family: GoogleSans + fonts: + - asset: ../../../fonts/GoogleSans/GoogleSans-Regular.ttf + - asset: ../../../fonts/GoogleSans/GoogleSans-Italic.ttf + style: italic + - asset: ../../../fonts/GoogleSans/GoogleSans-Medium.ttf + weight: 500 + - asset: ../../../fonts/GoogleSans/GoogleSans-SemiBold.ttf + weight: 600 + - asset: ../../../fonts/GoogleSans/GoogleSans-Bold.ttf + weight: 700 + - family: GoogleSansCode + fonts: + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Regular.ttf + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Medium.ttf + weight: 500 + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-SemiBold.ttf + weight: 600 \ No newline at end of file diff --git a/mobile/packages/ui/showcase/web/favicon.ico b/mobile/packages/ui/showcase/web/favicon.ico new file mode 100644 index 0000000000..7ec34e9e53 Binary files /dev/null and b/mobile/packages/ui/showcase/web/favicon.ico differ diff --git a/mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png b/mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..49fd3ae289 Binary files /dev/null and b/mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png differ diff --git a/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png b/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..a7220554bc Binary files /dev/null and b/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png differ diff --git a/mobile/packages/ui/showcase/web/icons/apple-icon-180.png b/mobile/packages/ui/showcase/web/icons/apple-icon-180.png new file mode 100644 index 0000000000..4e642631a3 Binary files /dev/null and b/mobile/packages/ui/showcase/web/icons/apple-icon-180.png differ diff --git a/mobile/packages/ui/showcase/web/index.html b/mobile/packages/ui/showcase/web/index.html new file mode 100644 index 0000000000..abf42ad1fd --- /dev/null +++ b/mobile/packages/ui/showcase/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + @immich/ui + + + + + + diff --git a/mobile/packages/ui/showcase/web/manifest.json b/mobile/packages/ui/showcase/web/manifest.json new file mode 100644 index 0000000000..25b44bd1ae --- /dev/null +++ b/mobile/packages/ui/showcase/web/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "@immich/ui Showcase", + "short_name": "@immich/ui", + "start_url": ".", + "display": "standalone", + "background_color": "#FCFCFD", + "theme_color": "#4250AF", + "description": "Immich UI component library showcase and documentation", + "orientation": "landscape", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/mobile/packages/ui/test/html_test.dart b/mobile/packages/ui/test/html_test.dart new file mode 100644 index 0000000000..27f68ff66c --- /dev/null +++ b/mobile/packages/ui/test/html_test.dart @@ -0,0 +1,266 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/components/html_text.dart'; + +import 'test_utils.dart'; + +/// Text.rich creates a nested structure: root -> wrapper -> actual children +List _getContentSpans(WidgetTester tester) { + final richText = tester.widget(find.byType(RichText)); + final root = richText.text as TextSpan; + + if (root.children?.isNotEmpty ?? false) { + final wrapper = root.children!.first; + if (wrapper is TextSpan && wrapper.children != null) { + return wrapper.children!; + } + } + return []; +} + +TextSpan _findSpan(List spans, String text) { + return spans.firstWhere( + (span) => span is TextSpan && span.text == text, + orElse: () => throw StateError('No span found with text: "$text"'), + ) as TextSpan; +} + +String _concatenateText(List spans) { + return spans.whereType().map((s) => s.text ?? '').join(); +} + +void _triggerTap(TextSpan span) { + final recognizer = span.recognizer; + if (recognizer is TapGestureRecognizer) { + recognizer.onTap?.call(); + } +} + +void main() { + group('ImmichHtmlText', () { + testWidgets('renders plain text without HTML tags', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is plain text'), + ); + + expect(find.text('This is plain text'), findsOneWidget); + }); + + testWidgets('handles mixed content with bold and links', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'This is an example of HTML text with bold.', + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + + final exampleSpan = _findSpan(spans, 'example'); + expect(exampleSpan.style?.fontWeight, FontWeight.bold); + + final boldSpan = _findSpan(spans, 'bold'); + expect(boldSpan.style?.fontWeight, FontWeight.bold); + + final linkSpan = _findSpan(spans, 'HTML text'); + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.style?.fontWeight, FontWeight.bold); + expect(linkSpan.recognizer, isA()); + + expect(_concatenateText(spans), 'This is an example of HTML text with bold.'); + }); + + testWidgets('applies text style properties', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText( + 'Test text', + style: TextStyle( + fontSize: 16, + color: Colors.purple, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + + final text = tester.widget(find.byType(Text)); + final richText = text.textSpan as TextSpan; + + expect(richText.style?.fontSize, 16); + expect(richText.style?.color, Colors.purple); + expect(text.textAlign, TextAlign.center); + expect(text.maxLines, 2); + expect(text.overflow, TextOverflow.ellipsis); + }); + + testWidgets('handles text with special characters', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('Text with & < > " \' characters'), + ); + + expect(find.byType(RichText), findsOneWidget); + + final spans = _getContentSpans(tester); + expect(_concatenateText(spans), 'Text with & < > " \' characters'); + }); + + group('bold', () { + testWidgets('renders bold text with tag', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is bold text'), + ); + + final spans = _getContentSpans(tester); + final boldSpan = _findSpan(spans, 'bold'); + + expect(boldSpan.style?.fontWeight, FontWeight.bold); + expect(_concatenateText(spans), 'This is bold text'); + }); + + testWidgets('renders bold text with tag', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is strong text'), + ); + + final spans = _getContentSpans(tester); + final strongSpan = _findSpan(spans, 'strong'); + + expect(strongSpan.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('handles nested bold tags', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('Text with bold and nested'), + ); + + final spans = _getContentSpans(tester); + + final nestedSpan = _findSpan(spans, 'nested'); + expect(nestedSpan.style?.fontWeight, FontWeight.bold); + + final boldSpan = _findSpan(spans, 'bold and '); + expect(boldSpan.style?.fontWeight, FontWeight.bold); + + expect(_concatenateText(spans), 'Text with bold and nested'); + }); + }); + + group('link', () { + testWidgets('renders link text with tag', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'This is a custom link text', + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'custom link'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isA()); + }); + + testWidgets('handles link tap with callback', (tester) async { + var linkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Tap here', + linkHandlers: {'link': () => linkTapped = true}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + expect(linkSpan.recognizer, isA()); + + _triggerTap(linkSpan); + expect(linkTapped, isTrue); + }); + + testWidgets('handles custom prefixed link tags', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Refer to docs and other', + linkHandlers: { + 'docs-link': () {}, + 'other-link': () {}, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final otherSpan = _findSpan(spans, 'other'); + + expect(docsSpan.style?.decoration, TextDecoration.underline); + expect(otherSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('applies custom link style', (tester) async { + const customLinkStyle = TextStyle( + color: Colors.red, + decoration: TextDecoration.overline, + ); + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Click here', + linkStyle: customLinkStyle, + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + + expect(linkSpan.style?.color, Colors.red); + expect(linkSpan.style?.decoration, TextDecoration.overline); + }); + + testWidgets('link without handler renders but is not tappable', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Link without handler: click me', + linkHandlers: {'other-link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'click me'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isNull); + }); + + testWidgets('handles multiple links with different handlers', (tester) async { + var firstLinkTapped = false; + var secondLinkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Go to docs or help', + linkHandlers: { + 'docs-link': () => firstLinkTapped = true, + 'help-link': () => secondLinkTapped = true, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final helpSpan = _findSpan(spans, 'help'); + + _triggerTap(docsSpan); + expect(firstLinkTapped, isTrue); + expect(secondLinkTapped, isFalse); + + _triggerTap(helpSpan); + expect(secondLinkTapped, isTrue); + }); + }); + }); +} diff --git a/mobile/packages/ui/test/test_utils.dart b/mobile/packages/ui/test/test_utils.dart new file mode 100644 index 0000000000..42cc74da87 --- /dev/null +++ b/mobile/packages/ui/test/test_utils.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension WidgetTesterExtension on WidgetTester { + /// Pumps a widget wrapped in MaterialApp and Scaffold for testing. + Future pumpTestWidget(Widget widget) { + return pumpWidget(MaterialApp(home: Scaffold(body: widget))); + } +} diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 1fe0fff6ae..d9f18b3007 100644 --- a/mobile/test/drift/main/generated/schema.dart +++ b/mobile/test/drift/main/generated/schema.dart @@ -21,6 +21,7 @@ import 'schema_v15.dart' as v15; import 'schema_v16.dart' as v16; import 'schema_v17.dart' as v17; import 'schema_v18.dart' as v18; +import 'schema_v19.dart' as v19; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -62,6 +63,8 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v17.DatabaseAtV17(db); case 18: return v18.DatabaseAtV18(db); + case 19: + return v19.DatabaseAtV19(db); default: throw MissingSchemaException(version, versions); } @@ -86,5 +89,6 @@ class GeneratedHelper implements SchemaInstantiationHelper { 16, 17, 18, + 19, ]; } diff --git a/mobile/test/drift/main/generated/schema_v19.dart b/mobile/test/drift/main/generated/schema_v19.dart new file mode 100644 index 0000000000..4a8dea806e --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v19.dart @@ -0,0 +1,8397 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class UserEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_entity'; + @override + Set get $primaryKey => {id}; + @override + UserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + ); + } + + @override + UserEntity createAlias(String alias) { + return UserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserEntityData extends DataClass implements Insertable { + final String id; + final String name; + final String email; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + const UserEntityData({ + required this.id, + required this.name, + required this.email, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + return map; + } + + factory UserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + }; + } + + UserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + }) => UserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + UserEntityData copyWithCompanion(UserEntityCompanion data) { + return UserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + ); + } + + @override + String toString() { + return (StringBuffer('UserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor); +} + +class UserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + const UserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }); + UserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + }); + } + + UserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + }) { + return UserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } +} + +class RemoteAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn localDateTime = + GeneratedColumn( + 'local_date_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn thumbHash = GeneratedColumn( + 'thumb_hash', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn livePhotoVideoId = GeneratedColumn( + 'live_photo_video_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stackId = GeneratedColumn( + 'stack_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn libraryId = GeneratedColumn( + 'library_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isEdited = GeneratedColumn( + 'is_edited', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_edited" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + isEdited, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + )!, + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + localDateTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_date_time'], + ), + thumbHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumb_hash'], + ), + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + livePhotoVideoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}live_photo_video_id'], + ), + visibility: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}visibility'], + )!, + stackId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}stack_id'], + ), + libraryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}library_id'], + ), + isEdited: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_edited'], + )!, + ); + } + + @override + RemoteAssetEntity createAlias(String alias) { + return RemoteAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String checksum; + final bool isFavorite; + final String ownerId; + final DateTime? localDateTime; + final String? thumbHash; + final DateTime? deletedAt; + final String? livePhotoVideoId; + final int visibility; + final String? stackId; + final String? libraryId; + final bool isEdited; + const RemoteAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.checksum, + required this.isFavorite, + required this.ownerId, + this.localDateTime, + this.thumbHash, + this.deletedAt, + this.livePhotoVideoId, + required this.visibility, + this.stackId, + this.libraryId, + required this.isEdited, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['checksum'] = Variable(checksum); + map['is_favorite'] = Variable(isFavorite); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || localDateTime != null) { + map['local_date_time'] = Variable(localDateTime); + } + if (!nullToAbsent || thumbHash != null) { + map['thumb_hash'] = Variable(thumbHash); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || livePhotoVideoId != null) { + map['live_photo_video_id'] = Variable(livePhotoVideoId); + } + map['visibility'] = Variable(visibility); + if (!nullToAbsent || stackId != null) { + map['stack_id'] = Variable(stackId); + } + if (!nullToAbsent || libraryId != null) { + map['library_id'] = Variable(libraryId); + } + map['is_edited'] = Variable(isEdited); + return map; + } + + factory RemoteAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ownerId: serializer.fromJson(json['ownerId']), + localDateTime: serializer.fromJson(json['localDateTime']), + thumbHash: serializer.fromJson(json['thumbHash']), + deletedAt: serializer.fromJson(json['deletedAt']), + livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), + visibility: serializer.fromJson(json['visibility']), + stackId: serializer.fromJson(json['stackId']), + libraryId: serializer.fromJson(json['libraryId']), + isEdited: serializer.fromJson(json['isEdited']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'ownerId': serializer.toJson(ownerId), + 'localDateTime': serializer.toJson(localDateTime), + 'thumbHash': serializer.toJson(thumbHash), + 'deletedAt': serializer.toJson(deletedAt), + 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), + 'visibility': serializer.toJson(visibility), + 'stackId': serializer.toJson(stackId), + 'libraryId': serializer.toJson(libraryId), + 'isEdited': serializer.toJson(isEdited), + }; + } + + RemoteAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? checksum, + bool? isFavorite, + String? ownerId, + Value localDateTime = const Value.absent(), + Value thumbHash = const Value.absent(), + Value deletedAt = const Value.absent(), + Value livePhotoVideoId = const Value.absent(), + int? visibility, + Value stackId = const Value.absent(), + Value libraryId = const Value.absent(), + bool? isEdited, + }) => RemoteAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime.present + ? localDateTime.value + : this.localDateTime, + thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + livePhotoVideoId: livePhotoVideoId.present + ? livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, + libraryId: libraryId.present ? libraryId.value : this.libraryId, + isEdited: isEdited ?? this.isEdited, + ); + RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { + return RemoteAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + localDateTime: data.localDateTime.present + ? data.localDateTime.value + : this.localDateTime, + thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + livePhotoVideoId: data.livePhotoVideoId.present + ? data.livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: data.visibility.present + ? data.visibility.value + : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, + libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId, + isEdited: data.isEdited.present ? data.isEdited.value : this.isEdited, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId, ') + ..write('isEdited: $isEdited') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + isEdited, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.ownerId == this.ownerId && + other.localDateTime == this.localDateTime && + other.thumbHash == this.thumbHash && + other.deletedAt == this.deletedAt && + other.livePhotoVideoId == this.livePhotoVideoId && + other.visibility == this.visibility && + other.stackId == this.stackId && + other.libraryId == this.libraryId && + other.isEdited == this.isEdited); +} + +class RemoteAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value ownerId; + final Value localDateTime; + final Value thumbHash; + final Value deletedAt; + final Value livePhotoVideoId; + final Value visibility; + final Value stackId; + final Value libraryId; + final Value isEdited; + const RemoteAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.ownerId = const Value.absent(), + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + this.visibility = const Value.absent(), + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + this.isEdited = const Value.absent(), + }); + RemoteAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String checksum, + this.isFavorite = const Value.absent(), + required String ownerId, + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + required int visibility, + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + this.isEdited = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + checksum = Value(checksum), + ownerId = Value(ownerId), + visibility = Value(visibility); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? ownerId, + Expression? localDateTime, + Expression? thumbHash, + Expression? deletedAt, + Expression? livePhotoVideoId, + Expression? visibility, + Expression? stackId, + Expression? libraryId, + Expression? isEdited, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (ownerId != null) 'owner_id': ownerId, + if (localDateTime != null) 'local_date_time': localDateTime, + if (thumbHash != null) 'thumb_hash': thumbHash, + if (deletedAt != null) 'deleted_at': deletedAt, + if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, + if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, + if (libraryId != null) 'library_id': libraryId, + if (isEdited != null) 'is_edited': isEdited, + }); + } + + RemoteAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? ownerId, + Value? localDateTime, + Value? thumbHash, + Value? deletedAt, + Value? livePhotoVideoId, + Value? visibility, + Value? stackId, + Value? libraryId, + Value? isEdited, + }) { + return RemoteAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime ?? this.localDateTime, + thumbHash: thumbHash ?? this.thumbHash, + deletedAt: deletedAt ?? this.deletedAt, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, + libraryId: libraryId ?? this.libraryId, + isEdited: isEdited ?? this.isEdited, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (localDateTime.present) { + map['local_date_time'] = Variable(localDateTime.value); + } + if (thumbHash.present) { + map['thumb_hash'] = Variable(thumbHash.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (livePhotoVideoId.present) { + map['live_photo_video_id'] = Variable(livePhotoVideoId.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + if (stackId.present) { + map['stack_id'] = Variable(stackId.value); + } + if (libraryId.present) { + map['library_id'] = Variable(libraryId.value); + } + if (isEdited.present) { + map['is_edited'] = Variable(isEdited.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId, ') + ..write('isEdited: $isEdited') + ..write(')')) + .toString(); + } +} + +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + primaryAssetId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}primary_asset_id'], + )!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId, + }) => StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId, + }) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + +class LocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn iCloudId = GeneratedColumn( + 'i_cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + iCloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}i_cloud_id'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + ); + } + + @override + LocalAssetEntity createAlias(String alias) { + return LocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + final int orientation; + final String? iCloudId; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + const LocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite, + required this.orientation, + this.iCloudId, + this.adjustmentTime, + this.latitude, + this.longitude, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + if (!nullToAbsent || iCloudId != null) { + map['i_cloud_id'] = Variable(iCloudId); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + return map; + } + + factory LocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + iCloudId: serializer.fromJson(json['iCloudId']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + 'iCloudId': serializer.toJson(iCloudId), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + }; + } + + LocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + Value iCloudId = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + }) => LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + ); + LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation && + other.iCloudId == this.iCloudId && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude); +} + +class LocalAssetEntityCompanion extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value orientation; + final Value iCloudId; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + const LocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + Expression? iCloudId, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + if (iCloudId != null) 'i_cloud_id': iCloudId, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + }); + } + + LocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation, + Value? iCloudId, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + }) { + return LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + iCloudId: iCloudId ?? this.iCloudId, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (iCloudId.present) { + map['i_cloud_id'] = Variable(iCloudId.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const CustomExpression('\'\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn thumbnailAssetId = GeneratedColumn( + 'thumbnail_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn isActivityEnabled = GeneratedColumn( + 'is_activity_enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn order = GeneratedColumn( + 'order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + thumbnailAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumbnail_asset_id'], + ), + isActivityEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_activity_enabled'], + )!, + order: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order'], + )!, + ); + } + + @override + RemoteAlbumEntity createAlias(String alias) { + return RemoteAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final int order; + const RemoteAlbumEntityData({ + required this.id, + required this.name, + required this.description, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['description'] = Variable(description); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || thumbnailAssetId != null) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId); + } + map['is_activity_enabled'] = Variable(isActivityEnabled); + map['order'] = Variable(order); + return map; + } + + factory RemoteAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + thumbnailAssetId: serializer.fromJson(json['thumbnailAssetId']), + isActivityEnabled: serializer.fromJson(json['isActivityEnabled']), + order: serializer.fromJson(json['order']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'thumbnailAssetId': serializer.toJson(thumbnailAssetId), + 'isActivityEnabled': serializer.toJson(isActivityEnabled), + 'order': serializer.toJson(order), + }; + } + + RemoteAlbumEntityData copyWith({ + String? id, + String? name, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + Value thumbnailAssetId = const Value.absent(), + bool? isActivityEnabled, + int? order, + }) => RemoteAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId.present + ? thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + RemoteAlbumEntityData copyWithCompanion(RemoteAlbumEntityCompanion data) { + return RemoteAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + description: data.description.present + ? data.description.value + : this.description, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + thumbnailAssetId: data.thumbnailAssetId.present + ? data.thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: data.isActivityEnabled.present + ? data.isActivityEnabled.value + : this.isActivityEnabled, + order: data.order.present ? data.order.value : this.order, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.description == this.description && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.thumbnailAssetId == this.thumbnailAssetId && + other.isActivityEnabled == this.isActivityEnabled && + other.order == this.order); +} + +class RemoteAlbumEntityCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value description; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value thumbnailAssetId; + final Value isActivityEnabled; + final Value order; + const RemoteAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + this.order = const Value.absent(), + }); + RemoteAlbumEntityCompanion.insert({ + required String id, + required String name, + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + required int order, + }) : id = Value(id), + name = Value(name), + ownerId = Value(ownerId), + order = Value(order); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? description, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? thumbnailAssetId, + Expression? isActivityEnabled, + Expression? order, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId, + if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled, + if (order != null) 'order': order, + }); + } + + RemoteAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? description, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? thumbnailAssetId, + Value? isActivityEnabled, + Value? order, + }) { + return RemoteAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (thumbnailAssetId.present) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId.value); + } + if (isActivityEnabled.present) { + map['is_activity_enabled'] = Variable(isActivityEnabled.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } +} + +class LocalAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn backupSelection = GeneratedColumn( + 'backup_selection', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn isIosSharedAlbum = GeneratedColumn( + 'is_ios_shared_album', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn linkedRemoteAlbumId = + GeneratedColumn( + 'linked_remote_album_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [ + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + backupSelection: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}backup_selection'], + )!, + isIosSharedAlbum: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_ios_shared_album'], + )!, + linkedRemoteAlbumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}linked_remote_album_id'], + ), + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumEntity createAlias(String alias) { + return LocalAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final DateTime updatedAt; + final int backupSelection; + final bool isIosSharedAlbum; + final String? linkedRemoteAlbumId; + final bool? marker_; + const LocalAlbumEntityData({ + required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + required this.isIosSharedAlbum, + this.linkedRemoteAlbumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['updated_at'] = Variable(updatedAt); + map['backup_selection'] = Variable(backupSelection); + map['is_ios_shared_album'] = Variable(isIosSharedAlbum); + if (!nullToAbsent || linkedRemoteAlbumId != null) { + map['linked_remote_album_id'] = Variable(linkedRemoteAlbumId); + } + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: serializer.fromJson(json['backupSelection']), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), + linkedRemoteAlbumId: serializer.fromJson( + json['linkedRemoteAlbumId'], + ), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(backupSelection), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), + 'linkedRemoteAlbumId': serializer.toJson(linkedRemoteAlbumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumEntityData copyWith({ + String? id, + String? name, + DateTime? updatedAt, + int? backupSelection, + bool? isIosSharedAlbum, + Value linkedRemoteAlbumId = const Value.absent(), + Value marker_ = const Value.absent(), + }) => LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId.present + ? linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumEntityData copyWithCompanion(LocalAlbumEntityCompanion data) { + return LocalAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, + linkedRemoteAlbumId: data.linkedRemoteAlbumId.present + ? data.linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && + other.linkedRemoteAlbumId == this.linkedRemoteAlbumId && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value updatedAt; + final Value backupSelection; + final Value isIosSharedAlbum; + final Value linkedRemoteAlbumId; + final Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.updatedAt = const Value.absent(), + this.backupSelection = const Value.absent(), + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumEntityCompanion.insert({ + required String id, + required String name, + this.updatedAt = const Value.absent(), + required int backupSelection, + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }) : id = Value(id), + name = Value(name), + backupSelection = Value(backupSelection); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? updatedAt, + Expression? backupSelection, + Expression? isIosSharedAlbum, + Expression? linkedRemoteAlbumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, + if (linkedRemoteAlbumId != null) + 'linked_remote_album_id': linkedRemoteAlbumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? updatedAt, + Value? backupSelection, + Value? isIosSharedAlbum, + Value? linkedRemoteAlbumId, + Value? marker_, + }) { + return LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = Variable(backupSelection.value); + } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = Variable(isIosSharedAlbum.value); + } + if (linkedRemoteAlbumId.present) { + map['linked_remote_album_id'] = Variable( + linkedRemoteAlbumId.value, + ); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class LocalAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [assetId, albumId, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + LocalAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumAssetEntity createAlias(String alias) { + return LocalAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + final bool? marker_; + const LocalAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumAssetEntityData copyWith({ + String? assetId, + String? albumId, + Value marker_ = const Value.absent(), + }) => LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumAssetEntityData copyWithCompanion( + LocalAlbumAssetEntityCompanion data, + ) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId && + other.marker_ == this.marker_); +} + +class LocalAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + final Value marker_; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + this.marker_ = const Value.absent(), + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + Value? marker_, + }) { + return LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class AuthUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AuthUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isAdmin = GeneratedColumn( + 'is_admin', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_admin" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn quotaSizeInBytes = GeneratedColumn( + 'quota_size_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn quotaUsageInBytes = GeneratedColumn( + 'quota_usage_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn pinCode = GeneratedColumn( + 'pin_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'auth_user_entity'; + @override + Set get $primaryKey => {id}; + @override + AuthUserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AuthUserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + isAdmin: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_admin'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + quotaSizeInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_size_in_bytes'], + )!, + quotaUsageInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_usage_in_bytes'], + )!, + pinCode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pin_code'], + ), + ); + } + + @override + AuthUserEntity createAlias(String alias) { + return AuthUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AuthUserEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String email; + final bool isAdmin; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + final int quotaSizeInBytes; + final int quotaUsageInBytes; + final String? pinCode; + const AuthUserEntityData({ + required this.id, + required this.name, + required this.email, + required this.isAdmin, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + required this.quotaSizeInBytes, + required this.quotaUsageInBytes, + this.pinCode, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['is_admin'] = Variable(isAdmin); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes); + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes); + if (!nullToAbsent || pinCode != null) { + map['pin_code'] = Variable(pinCode); + } + return map; + } + + factory AuthUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AuthUserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + isAdmin: serializer.fromJson(json['isAdmin']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + quotaSizeInBytes: serializer.fromJson(json['quotaSizeInBytes']), + quotaUsageInBytes: serializer.fromJson(json['quotaUsageInBytes']), + pinCode: serializer.fromJson(json['pinCode']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'isAdmin': serializer.toJson(isAdmin), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + 'quotaSizeInBytes': serializer.toJson(quotaSizeInBytes), + 'quotaUsageInBytes': serializer.toJson(quotaUsageInBytes), + 'pinCode': serializer.toJson(pinCode), + }; + } + + AuthUserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? isAdmin, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + int? quotaSizeInBytes, + int? quotaUsageInBytes, + Value pinCode = const Value.absent(), + }) => AuthUserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode.present ? pinCode.value : this.pinCode, + ); + AuthUserEntityData copyWithCompanion(AuthUserEntityCompanion data) { + return AuthUserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + isAdmin: data.isAdmin.present ? data.isAdmin.value : this.isAdmin, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + quotaSizeInBytes: data.quotaSizeInBytes.present + ? data.quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: data.quotaUsageInBytes.present + ? data.quotaUsageInBytes.value + : this.quotaUsageInBytes, + pinCode: data.pinCode.present ? data.pinCode.value : this.pinCode, + ); + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AuthUserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.isAdmin == this.isAdmin && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor && + other.quotaSizeInBytes == this.quotaSizeInBytes && + other.quotaUsageInBytes == this.quotaUsageInBytes && + other.pinCode == this.pinCode); +} + +class AuthUserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value isAdmin; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + final Value quotaSizeInBytes; + final Value quotaUsageInBytes; + final Value pinCode; + const AuthUserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }); + AuthUserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + required int avatarColor, + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email), + avatarColor = Value(avatarColor); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? isAdmin, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + Expression? quotaSizeInBytes, + Expression? quotaUsageInBytes, + Expression? pinCode, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (isAdmin != null) 'is_admin': isAdmin, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + if (quotaSizeInBytes != null) 'quota_size_in_bytes': quotaSizeInBytes, + if (quotaUsageInBytes != null) 'quota_usage_in_bytes': quotaUsageInBytes, + if (pinCode != null) 'pin_code': pinCode, + }); + } + + AuthUserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? isAdmin, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + Value? quotaSizeInBytes, + Value? quotaUsageInBytes, + Value? pinCode, + }) { + return AuthUserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode ?? this.pinCode, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (isAdmin.present) { + map['is_admin'] = Variable(isAdmin.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + if (quotaSizeInBytes.present) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes.value); + } + if (quotaUsageInBytes.present) { + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes.value); + } + if (pinCode.present) { + map['pin_code'] = Variable(pinCode.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } +} + +class UserMetadataEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserMetadataEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + ); + @override + List get $columns => [userId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_metadata_entity'; + @override + Set get $primaryKey => {userId, key}; + @override + UserMetadataEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserMetadataEntityData( + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + key: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + UserMetadataEntity createAlias(String alias) { + return UserMetadataEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserMetadataEntityData extends DataClass + implements Insertable { + final String userId; + final int key; + final Uint8List value; + const UserMetadataEntityData({ + required this.userId, + required this.key, + required this.value, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['user_id'] = Variable(userId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory UserMetadataEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserMetadataEntityData( + userId: serializer.fromJson(json['userId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + UserMetadataEntityData copyWith({ + String? userId, + int? key, + Uint8List? value, + }) => UserMetadataEntityData( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + UserMetadataEntityData copyWithCompanion(UserMetadataEntityCompanion data) { + return UserMetadataEntityData( + userId: data.userId.present ? data.userId.value : this.userId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityData(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(userId, key, $driftBlobEquality.hash(value)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserMetadataEntityData && + other.userId == this.userId && + other.key == this.key && + $driftBlobEquality.equals(other.value, this.value)); +} + +class UserMetadataEntityCompanion + extends UpdateCompanion { + final Value userId; + final Value key; + final Value value; + const UserMetadataEntityCompanion({ + this.userId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + }); + UserMetadataEntityCompanion.insert({ + required String userId, + required int key, + required Uint8List value, + }) : userId = Value(userId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? userId, + Expression? key, + Expression? value, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + UserMetadataEntityCompanion copyWith({ + Value? userId, + Value? key, + Value? value, + }) { + return UserMetadataEntityCompanion( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityCompanion(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} + +class PartnerEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PartnerEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn sharedById = GeneratedColumn( + 'shared_by_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn sharedWithId = GeneratedColumn( + 'shared_with_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn inTimeline = GeneratedColumn( + 'in_timeline', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("in_timeline" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [sharedById, sharedWithId, inTimeline]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'partner_entity'; + @override + Set get $primaryKey => {sharedById, sharedWithId}; + @override + PartnerEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PartnerEntityData( + sharedById: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_by_id'], + )!, + sharedWithId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_with_id'], + )!, + inTimeline: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}in_timeline'], + )!, + ); + } + + @override + PartnerEntity createAlias(String alias) { + return PartnerEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PartnerEntityData extends DataClass + implements Insertable { + final String sharedById; + final String sharedWithId; + final bool inTimeline; + const PartnerEntityData({ + required this.sharedById, + required this.sharedWithId, + required this.inTimeline, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['shared_by_id'] = Variable(sharedById); + map['shared_with_id'] = Variable(sharedWithId); + map['in_timeline'] = Variable(inTimeline); + return map; + } + + factory PartnerEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PartnerEntityData( + sharedById: serializer.fromJson(json['sharedById']), + sharedWithId: serializer.fromJson(json['sharedWithId']), + inTimeline: serializer.fromJson(json['inTimeline']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sharedById': serializer.toJson(sharedById), + 'sharedWithId': serializer.toJson(sharedWithId), + 'inTimeline': serializer.toJson(inTimeline), + }; + } + + PartnerEntityData copyWith({ + String? sharedById, + String? sharedWithId, + bool? inTimeline, + }) => PartnerEntityData( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + PartnerEntityData copyWithCompanion(PartnerEntityCompanion data) { + return PartnerEntityData( + sharedById: data.sharedById.present + ? data.sharedById.value + : this.sharedById, + sharedWithId: data.sharedWithId.present + ? data.sharedWithId.value + : this.sharedWithId, + inTimeline: data.inTimeline.present + ? data.inTimeline.value + : this.inTimeline, + ); + } + + @override + String toString() { + return (StringBuffer('PartnerEntityData(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(sharedById, sharedWithId, inTimeline); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartnerEntityData && + other.sharedById == this.sharedById && + other.sharedWithId == this.sharedWithId && + other.inTimeline == this.inTimeline); +} + +class PartnerEntityCompanion extends UpdateCompanion { + final Value sharedById; + final Value sharedWithId; + final Value inTimeline; + const PartnerEntityCompanion({ + this.sharedById = const Value.absent(), + this.sharedWithId = const Value.absent(), + this.inTimeline = const Value.absent(), + }); + PartnerEntityCompanion.insert({ + required String sharedById, + required String sharedWithId, + this.inTimeline = const Value.absent(), + }) : sharedById = Value(sharedById), + sharedWithId = Value(sharedWithId); + static Insertable custom({ + Expression? sharedById, + Expression? sharedWithId, + Expression? inTimeline, + }) { + return RawValuesInsertable({ + if (sharedById != null) 'shared_by_id': sharedById, + if (sharedWithId != null) 'shared_with_id': sharedWithId, + if (inTimeline != null) 'in_timeline': inTimeline, + }); + } + + PartnerEntityCompanion copyWith({ + Value? sharedById, + Value? sharedWithId, + Value? inTimeline, + }) { + return PartnerEntityCompanion( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sharedById.present) { + map['shared_by_id'] = Variable(sharedById.value); + } + if (sharedWithId.present) { + map['shared_with_id'] = Variable(sharedWithId.value); + } + if (inTimeline.present) { + map['in_timeline'] = Variable(inTimeline.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PartnerEntityCompanion(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } +} + +class RemoteExifEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteExifEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn city = GeneratedColumn( + 'city', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn state = GeneratedColumn( + 'state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn country = GeneratedColumn( + 'country', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn dateTimeOriginal = + GeneratedColumn( + 'date_time_original', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn exposureTime = GeneratedColumn( + 'exposure_time', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn fNumber = GeneratedColumn( + 'f_number', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn focalLength = GeneratedColumn( + 'focal_length', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn iso = GeneratedColumn( + 'iso', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn make = GeneratedColumn( + 'make', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn model = GeneratedColumn( + 'model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn lens = GeneratedColumn( + 'lens', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn projectionType = GeneratedColumn( + 'projection_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_exif_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteExifEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteExifEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + city: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}city'], + ), + state: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state'], + ), + country: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}country'], + ), + dateTimeOriginal: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}date_time_original'], + ), + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + exposureTime: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exposure_time'], + ), + fNumber: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}f_number'], + ), + fileSize: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}file_size'], + ), + focalLength: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}focal_length'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + iso: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}iso'], + ), + make: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}make'], + ), + model: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}model'], + ), + lens: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}lens'], + ), + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}orientation'], + ), + timeZone: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}time_zone'], + ), + rating: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}rating'], + ), + projectionType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_type'], + ), + ); + } + + @override + RemoteExifEntity createAlias(String alias) { + return RemoteExifEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteExifEntityData extends DataClass + implements Insertable { + final String assetId; + final String? city; + final String? state; + final String? country; + final DateTime? dateTimeOriginal; + final String? description; + final int? height; + final int? width; + final String? exposureTime; + final double? fNumber; + final int? fileSize; + final double? focalLength; + final double? latitude; + final double? longitude; + final int? iso; + final String? make; + final String? model; + final String? lens; + final String? orientation; + final String? timeZone; + final int? rating; + final String? projectionType; + const RemoteExifEntityData({ + required this.assetId, + this.city, + this.state, + this.country, + this.dateTimeOriginal, + this.description, + this.height, + this.width, + this.exposureTime, + this.fNumber, + this.fileSize, + this.focalLength, + this.latitude, + this.longitude, + this.iso, + this.make, + this.model, + this.lens, + this.orientation, + this.timeZone, + this.rating, + this.projectionType, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || city != null) { + map['city'] = Variable(city); + } + if (!nullToAbsent || state != null) { + map['state'] = Variable(state); + } + if (!nullToAbsent || country != null) { + map['country'] = Variable(country); + } + if (!nullToAbsent || dateTimeOriginal != null) { + map['date_time_original'] = Variable(dateTimeOriginal); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || exposureTime != null) { + map['exposure_time'] = Variable(exposureTime); + } + if (!nullToAbsent || fNumber != null) { + map['f_number'] = Variable(fNumber); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + if (!nullToAbsent || focalLength != null) { + map['focal_length'] = Variable(focalLength); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + if (!nullToAbsent || iso != null) { + map['iso'] = Variable(iso); + } + if (!nullToAbsent || make != null) { + map['make'] = Variable(make); + } + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || lens != null) { + map['lens'] = Variable(lens); + } + if (!nullToAbsent || orientation != null) { + map['orientation'] = Variable(orientation); + } + if (!nullToAbsent || timeZone != null) { + map['time_zone'] = Variable(timeZone); + } + if (!nullToAbsent || rating != null) { + map['rating'] = Variable(rating); + } + if (!nullToAbsent || projectionType != null) { + map['projection_type'] = Variable(projectionType); + } + return map; + } + + factory RemoteExifEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteExifEntityData( + assetId: serializer.fromJson(json['assetId']), + city: serializer.fromJson(json['city']), + state: serializer.fromJson(json['state']), + country: serializer.fromJson(json['country']), + dateTimeOriginal: serializer.fromJson( + json['dateTimeOriginal'], + ), + description: serializer.fromJson(json['description']), + height: serializer.fromJson(json['height']), + width: serializer.fromJson(json['width']), + exposureTime: serializer.fromJson(json['exposureTime']), + fNumber: serializer.fromJson(json['fNumber']), + fileSize: serializer.fromJson(json['fileSize']), + focalLength: serializer.fromJson(json['focalLength']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + iso: serializer.fromJson(json['iso']), + make: serializer.fromJson(json['make']), + model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), + orientation: serializer.fromJson(json['orientation']), + timeZone: serializer.fromJson(json['timeZone']), + rating: serializer.fromJson(json['rating']), + projectionType: serializer.fromJson(json['projectionType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'city': serializer.toJson(city), + 'state': serializer.toJson(state), + 'country': serializer.toJson(country), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + 'description': serializer.toJson(description), + 'height': serializer.toJson(height), + 'width': serializer.toJson(width), + 'exposureTime': serializer.toJson(exposureTime), + 'fNumber': serializer.toJson(fNumber), + 'fileSize': serializer.toJson(fileSize), + 'focalLength': serializer.toJson(focalLength), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'iso': serializer.toJson(iso), + 'make': serializer.toJson(make), + 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), + 'orientation': serializer.toJson(orientation), + 'timeZone': serializer.toJson(timeZone), + 'rating': serializer.toJson(rating), + 'projectionType': serializer.toJson(projectionType), + }; + } + + RemoteExifEntityData copyWith({ + String? assetId, + Value city = const Value.absent(), + Value state = const Value.absent(), + Value country = const Value.absent(), + Value dateTimeOriginal = const Value.absent(), + Value description = const Value.absent(), + Value height = const Value.absent(), + Value width = const Value.absent(), + Value exposureTime = const Value.absent(), + Value fNumber = const Value.absent(), + Value fileSize = const Value.absent(), + Value focalLength = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value iso = const Value.absent(), + Value make = const Value.absent(), + Value model = const Value.absent(), + Value lens = const Value.absent(), + Value orientation = const Value.absent(), + Value timeZone = const Value.absent(), + Value rating = const Value.absent(), + Value projectionType = const Value.absent(), + }) => RemoteExifEntityData( + assetId: assetId ?? this.assetId, + city: city.present ? city.value : this.city, + state: state.present ? state.value : this.state, + country: country.present ? country.value : this.country, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + description: description.present ? description.value : this.description, + height: height.present ? height.value : this.height, + width: width.present ? width.value : this.width, + exposureTime: exposureTime.present ? exposureTime.value : this.exposureTime, + fNumber: fNumber.present ? fNumber.value : this.fNumber, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + focalLength: focalLength.present ? focalLength.value : this.focalLength, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + iso: iso.present ? iso.value : this.iso, + make: make.present ? make.value : this.make, + model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, + orientation: orientation.present ? orientation.value : this.orientation, + timeZone: timeZone.present ? timeZone.value : this.timeZone, + rating: rating.present ? rating.value : this.rating, + projectionType: projectionType.present + ? projectionType.value + : this.projectionType, + ); + RemoteExifEntityData copyWithCompanion(RemoteExifEntityCompanion data) { + return RemoteExifEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + city: data.city.present ? data.city.value : this.city, + state: data.state.present ? data.state.value : this.state, + country: data.country.present ? data.country.value : this.country, + dateTimeOriginal: data.dateTimeOriginal.present + ? data.dateTimeOriginal.value + : this.dateTimeOriginal, + description: data.description.present + ? data.description.value + : this.description, + height: data.height.present ? data.height.value : this.height, + width: data.width.present ? data.width.value : this.width, + exposureTime: data.exposureTime.present + ? data.exposureTime.value + : this.exposureTime, + fNumber: data.fNumber.present ? data.fNumber.value : this.fNumber, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + focalLength: data.focalLength.present + ? data.focalLength.value + : this.focalLength, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + iso: data.iso.present ? data.iso.value : this.iso, + make: data.make.present ? data.make.value : this.make, + model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, + rating: data.rating.present ? data.rating.value : this.rating, + projectionType: data.projectionType.present + ? data.projectionType.value + : this.projectionType, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityData(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteExifEntityData && + other.assetId == this.assetId && + other.city == this.city && + other.state == this.state && + other.country == this.country && + other.dateTimeOriginal == this.dateTimeOriginal && + other.description == this.description && + other.height == this.height && + other.width == this.width && + other.exposureTime == this.exposureTime && + other.fNumber == this.fNumber && + other.fileSize == this.fileSize && + other.focalLength == this.focalLength && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.iso == this.iso && + other.make == this.make && + other.model == this.model && + other.lens == this.lens && + other.orientation == this.orientation && + other.timeZone == this.timeZone && + other.rating == this.rating && + other.projectionType == this.projectionType); +} + +class RemoteExifEntityCompanion extends UpdateCompanion { + final Value assetId; + final Value city; + final Value state; + final Value country; + final Value dateTimeOriginal; + final Value description; + final Value height; + final Value width; + final Value exposureTime; + final Value fNumber; + final Value fileSize; + final Value focalLength; + final Value latitude; + final Value longitude; + final Value iso; + final Value make; + final Value model; + final Value lens; + final Value orientation; + final Value timeZone; + final Value rating; + final Value projectionType; + const RemoteExifEntityCompanion({ + this.assetId = const Value.absent(), + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }); + RemoteExifEntityCompanion.insert({ + required String assetId, + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? city, + Expression? state, + Expression? country, + Expression? dateTimeOriginal, + Expression? description, + Expression? height, + Expression? width, + Expression? exposureTime, + Expression? fNumber, + Expression? fileSize, + Expression? focalLength, + Expression? latitude, + Expression? longitude, + Expression? iso, + Expression? make, + Expression? model, + Expression? lens, + Expression? orientation, + Expression? timeZone, + Expression? rating, + Expression? projectionType, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (country != null) 'country': country, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + if (description != null) 'description': description, + if (height != null) 'height': height, + if (width != null) 'width': width, + if (exposureTime != null) 'exposure_time': exposureTime, + if (fNumber != null) 'f_number': fNumber, + if (fileSize != null) 'file_size': fileSize, + if (focalLength != null) 'focal_length': focalLength, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (iso != null) 'iso': iso, + if (make != null) 'make': make, + if (model != null) 'model': model, + if (lens != null) 'lens': lens, + if (orientation != null) 'orientation': orientation, + if (timeZone != null) 'time_zone': timeZone, + if (rating != null) 'rating': rating, + if (projectionType != null) 'projection_type': projectionType, + }); + } + + RemoteExifEntityCompanion copyWith({ + Value? assetId, + Value? city, + Value? state, + Value? country, + Value? dateTimeOriginal, + Value? description, + Value? height, + Value? width, + Value? exposureTime, + Value? fNumber, + Value? fileSize, + Value? focalLength, + Value? latitude, + Value? longitude, + Value? iso, + Value? make, + Value? model, + Value? lens, + Value? orientation, + Value? timeZone, + Value? rating, + Value? projectionType, + }) { + return RemoteExifEntityCompanion( + assetId: assetId ?? this.assetId, + city: city ?? this.city, + state: state ?? this.state, + country: country ?? this.country, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + description: description ?? this.description, + height: height ?? this.height, + width: width ?? this.width, + exposureTime: exposureTime ?? this.exposureTime, + fNumber: fNumber ?? this.fNumber, + fileSize: fileSize ?? this.fileSize, + focalLength: focalLength ?? this.focalLength, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + iso: iso ?? this.iso, + make: make ?? this.make, + model: model ?? this.model, + lens: lens ?? this.lens, + orientation: orientation ?? this.orientation, + timeZone: timeZone ?? this.timeZone, + rating: rating ?? this.rating, + projectionType: projectionType ?? this.projectionType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (city.present) { + map['city'] = Variable(city.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (country.present) { + map['country'] = Variable(country.value); + } + if (dateTimeOriginal.present) { + map['date_time_original'] = Variable(dateTimeOriginal.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (exposureTime.present) { + map['exposure_time'] = Variable(exposureTime.value); + } + if (fNumber.present) { + map['f_number'] = Variable(fNumber.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + if (focalLength.present) { + map['focal_length'] = Variable(focalLength.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (iso.present) { + map['iso'] = Variable(iso.value); + } + if (make.present) { + map['make'] = Variable(make.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (lens.present) { + map['lens'] = Variable(lens.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } + if (rating.present) { + map['rating'] = Variable(rating.value); + } + if (projectionType.present) { + map['projection_type'] = Variable(projectionType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + RemoteAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + ); + } + + @override + RemoteAlbumAssetEntity createAlias(String alias) { + return RemoteAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const RemoteAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory RemoteAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + RemoteAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + RemoteAlbumAssetEntityData copyWithCompanion( + RemoteAlbumAssetEntityCompanion data, + ) { + return RemoteAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class RemoteAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const RemoteAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + RemoteAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + RemoteAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + }) { + return RemoteAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn role = GeneratedColumn( + 'role', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [albumId, userId, role]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_user_entity'; + @override + Set get $primaryKey => {albumId, userId}; + @override + RemoteAlbumUserEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumUserEntityData( + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + role: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}role'], + )!, + ); + } + + @override + RemoteAlbumUserEntity createAlias(String alias) { + return RemoteAlbumUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumUserEntityData extends DataClass + implements Insertable { + final String albumId; + final String userId; + final int role; + const RemoteAlbumUserEntityData({ + required this.albumId, + required this.userId, + required this.role, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album_id'] = Variable(albumId); + map['user_id'] = Variable(userId); + map['role'] = Variable(role); + return map; + } + + factory RemoteAlbumUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumUserEntityData( + albumId: serializer.fromJson(json['albumId']), + userId: serializer.fromJson(json['userId']), + role: serializer.fromJson(json['role']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'albumId': serializer.toJson(albumId), + 'userId': serializer.toJson(userId), + 'role': serializer.toJson(role), + }; + } + + RemoteAlbumUserEntityData copyWith({ + String? albumId, + String? userId, + int? role, + }) => RemoteAlbumUserEntityData( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + RemoteAlbumUserEntityData copyWithCompanion( + RemoteAlbumUserEntityCompanion data, + ) { + return RemoteAlbumUserEntityData( + albumId: data.albumId.present ? data.albumId.value : this.albumId, + userId: data.userId.present ? data.userId.value : this.userId, + role: data.role.present ? data.role.value : this.role, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityData(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(albumId, userId, role); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumUserEntityData && + other.albumId == this.albumId && + other.userId == this.userId && + other.role == this.role); +} + +class RemoteAlbumUserEntityCompanion + extends UpdateCompanion { + final Value albumId; + final Value userId; + final Value role; + const RemoteAlbumUserEntityCompanion({ + this.albumId = const Value.absent(), + this.userId = const Value.absent(), + this.role = const Value.absent(), + }); + RemoteAlbumUserEntityCompanion.insert({ + required String albumId, + required String userId, + required int role, + }) : albumId = Value(albumId), + userId = Value(userId), + role = Value(role); + static Insertable custom({ + Expression? albumId, + Expression? userId, + Expression? role, + }) { + return RawValuesInsertable({ + if (albumId != null) 'album_id': albumId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + }); + } + + RemoteAlbumUserEntityCompanion copyWith({ + Value? albumId, + Value? userId, + Value? role, + }) { + return RemoteAlbumUserEntityCompanion( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityCompanion(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } +} + +class RemoteAssetCloudIdEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetCloudIdEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn cloudId = GeneratedColumn( + 'cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_cloud_id_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteAssetCloudIdEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetCloudIdEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + cloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cloud_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + ); + } + + @override + RemoteAssetCloudIdEntity createAlias(String alias) { + return RemoteAssetCloudIdEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetCloudIdEntityData extends DataClass + implements Insertable { + final String assetId; + final String? cloudId; + final DateTime? createdAt; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + const RemoteAssetCloudIdEntityData({ + required this.assetId, + this.cloudId, + this.createdAt, + this.adjustmentTime, + this.latitude, + this.longitude, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || cloudId != null) { + map['cloud_id'] = Variable(cloudId); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + return map; + } + + factory RemoteAssetCloudIdEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetCloudIdEntityData( + assetId: serializer.fromJson(json['assetId']), + cloudId: serializer.fromJson(json['cloudId']), + createdAt: serializer.fromJson(json['createdAt']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'cloudId': serializer.toJson(cloudId), + 'createdAt': serializer.toJson(createdAt), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + }; + } + + RemoteAssetCloudIdEntityData copyWith({ + String? assetId, + Value cloudId = const Value.absent(), + Value createdAt = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + }) => RemoteAssetCloudIdEntityData( + assetId: assetId ?? this.assetId, + cloudId: cloudId.present ? cloudId.value : this.cloudId, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + ); + RemoteAssetCloudIdEntityData copyWithCompanion( + RemoteAssetCloudIdEntityCompanion data, + ) { + return RemoteAssetCloudIdEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityData(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetCloudIdEntityData && + other.assetId == this.assetId && + other.cloudId == this.cloudId && + other.createdAt == this.createdAt && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude); +} + +class RemoteAssetCloudIdEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value cloudId; + final Value createdAt; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + const RemoteAssetCloudIdEntityCompanion({ + this.assetId = const Value.absent(), + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }); + RemoteAssetCloudIdEntityCompanion.insert({ + required String assetId, + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? cloudId, + Expression? createdAt, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (cloudId != null) 'cloud_id': cloudId, + if (createdAt != null) 'created_at': createdAt, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + }); + } + + RemoteAssetCloudIdEntityCompanion copyWith({ + Value? assetId, + Value? cloudId, + Value? createdAt, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + }) { + return RemoteAssetCloudIdEntityCompanion( + assetId: assetId ?? this.assetId, + cloudId: cloudId ?? this.cloudId, + createdAt: createdAt ?? this.createdAt, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (cloudId.present) { + map['cloud_id'] = Variable(cloudId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } +} + +class MemoryEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn data = GeneratedColumn( + 'data', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isSaved = GeneratedColumn( + 'is_saved', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_saved" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn memoryAt = GeneratedColumn( + 'memory_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + late final GeneratedColumn seenAt = GeneratedColumn( + 'seen_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn showAt = GeneratedColumn( + 'show_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn hideAt = GeneratedColumn( + 'hide_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + Set get $primaryKey => {id}; + @override + MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + data: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}data'], + )!, + isSaved: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_saved'], + )!, + memoryAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}memory_at'], + )!, + seenAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}seen_at'], + ), + showAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}show_at'], + ), + hideAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}hide_at'], + ), + ); + } + + @override + MemoryEntity createAlias(String alias) { + return MemoryEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final int type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['owner_id'] = Variable(ownerId); + map['type'] = Variable(type); + map['data'] = Variable(data); + map['is_saved'] = Variable(isSaved); + map['memory_at'] = Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: serializer.fromJson(json['type']), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer.toJson(type), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + MemoryEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + String? ownerId, + int? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + Value seenAt = const Value.absent(), + Value showAt = const Value.absent(), + Value hideAt = const Value.absent(), + }) => MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(MemoryEntityCompanion data) { + return MemoryEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value ownerId; + final Value type; + final Value data; + final Value isSaved; + final Value memoryAt; + final Value seenAt; + final Value showAt; + final Value hideAt; + const MemoryEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.type = const Value.absent(), + this.data = const Value.absent(), + this.isSaved = const Value.absent(), + this.memoryAt = const Value.absent(), + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + required String ownerId, + required int type, + required String data, + this.isSaved = const Value.absent(), + required DateTime memoryAt, + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + type = Value(type), + data = Value(data), + memoryAt = Value(memoryAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? ownerId, + Expression? type, + Expression? data, + Expression? isSaved, + Expression? memoryAt, + Expression? seenAt, + Expression? showAt, + Expression? hideAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + MemoryEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? ownerId, + Value? type, + Value? data, + Value? isSaved, + Value? memoryAt, + Value? seenAt, + Value? showAt, + Value? hideAt, + }) { + return MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} + +class MemoryAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn memoryId = GeneratedColumn( + 'memory_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + Set get $primaryKey => {assetId, memoryId}; + @override + MemoryAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + memoryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}memory_id'], + )!, + ); + } + + @override + MemoryAssetEntity createAlias(String alias) { + return MemoryAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['memory_id'] = Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.memoryId = const Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = Value(assetId), + memoryId = Value(memoryId); + static Insertable custom({ + Expression? assetId, + Expression? memoryId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + MemoryAssetEntityCompanion copyWith({ + Value? assetId, + Value? memoryId, + }) { + return MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} + +class PersonEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PersonEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn faceAssetId = GeneratedColumn( + 'face_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + ); + late final GeneratedColumn isHidden = GeneratedColumn( + 'is_hidden', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_hidden" IN (0, 1))', + ), + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn birthDate = GeneratedColumn( + 'birth_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + Set get $primaryKey => {id}; + @override + PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PersonEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + faceAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}face_asset_id'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + isHidden: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_hidden'], + )!, + color: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color'], + ), + birthDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}birth_date'], + ), + ); + } + + @override + PersonEntity createAlias(String alias) { + return PersonEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['name'] = Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = Variable(faceAssetId); + } + map['is_favorite'] = Variable(isFavorite); + map['is_hidden'] = Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + PersonEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + Value faceAssetId = const Value.absent(), + bool? isFavorite, + bool? isHidden, + Value color = const Value.absent(), + Value birthDate = const Value.absent(), + }) => PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: data.faceAssetId.present + ? data.faceAssetId.value + : this.faceAssetId, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value name; + final Value faceAssetId; + final Value isFavorite; + final Value isHidden; + final Value color; + final Value birthDate; + const PersonEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.name = const Value.absent(), + this.faceAssetId = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isHidden = const Value.absent(), + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const Value.absent(), + required bool isFavorite, + required bool isHidden, + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + name = Value(name), + isFavorite = Value(isFavorite), + isHidden = Value(isHidden); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? name, + Expression? faceAssetId, + Expression? isFavorite, + Expression? isHidden, + Expression? color, + Expression? birthDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + PersonEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? name, + Value? faceAssetId, + Value? isFavorite, + Value? isHidden, + Value? color, + Value? birthDate, + }) { + return PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = Variable(faceAssetId.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = Variable(isHidden.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} + +class AssetFaceEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AssetFaceEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn personId = GeneratedColumn( + 'person_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES person_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn imageWidth = GeneratedColumn( + 'image_width', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn imageHeight = GeneratedColumn( + 'image_height', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX1 = GeneratedColumn( + 'bounding_box_x1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY1 = GeneratedColumn( + 'bounding_box_y1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX2 = GeneratedColumn( + 'bounding_box_x2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY2 = GeneratedColumn( + 'bounding_box_y2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_face_entity'; + @override + Set get $primaryKey => {id}; + @override + AssetFaceEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AssetFaceEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + personId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}person_id'], + ), + imageWidth: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_width'], + )!, + imageHeight: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_height'], + )!, + boundingBoxX1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x1'], + )!, + boundingBoxY1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y1'], + )!, + boundingBoxX2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x2'], + )!, + boundingBoxY2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y2'], + )!, + sourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_type'], + )!, + ); + } + + @override + AssetFaceEntity createAlias(String alias) { + return AssetFaceEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetFaceEntityData extends DataClass + implements Insertable { + final String id; + final String assetId; + final String? personId; + final int imageWidth; + final int imageHeight; + final int boundingBoxX1; + final int boundingBoxY1; + final int boundingBoxX2; + final int boundingBoxY2; + final String sourceType; + const AssetFaceEntityData({ + required this.id, + required this.assetId, + this.personId, + required this.imageWidth, + required this.imageHeight, + required this.boundingBoxX1, + required this.boundingBoxY1, + required this.boundingBoxX2, + required this.boundingBoxY2, + required this.sourceType, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || personId != null) { + map['person_id'] = Variable(personId); + } + map['image_width'] = Variable(imageWidth); + map['image_height'] = Variable(imageHeight); + map['bounding_box_x1'] = Variable(boundingBoxX1); + map['bounding_box_y1'] = Variable(boundingBoxY1); + map['bounding_box_x2'] = Variable(boundingBoxX2); + map['bounding_box_y2'] = Variable(boundingBoxY2); + map['source_type'] = Variable(sourceType); + return map; + } + + factory AssetFaceEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AssetFaceEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + personId: serializer.fromJson(json['personId']), + imageWidth: serializer.fromJson(json['imageWidth']), + imageHeight: serializer.fromJson(json['imageHeight']), + boundingBoxX1: serializer.fromJson(json['boundingBoxX1']), + boundingBoxY1: serializer.fromJson(json['boundingBoxY1']), + boundingBoxX2: serializer.fromJson(json['boundingBoxX2']), + boundingBoxY2: serializer.fromJson(json['boundingBoxY2']), + sourceType: serializer.fromJson(json['sourceType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'personId': serializer.toJson(personId), + 'imageWidth': serializer.toJson(imageWidth), + 'imageHeight': serializer.toJson(imageHeight), + 'boundingBoxX1': serializer.toJson(boundingBoxX1), + 'boundingBoxY1': serializer.toJson(boundingBoxY1), + 'boundingBoxX2': serializer.toJson(boundingBoxX2), + 'boundingBoxY2': serializer.toJson(boundingBoxY2), + 'sourceType': serializer.toJson(sourceType), + }; + } + + AssetFaceEntityData copyWith({ + String? id, + String? assetId, + Value personId = const Value.absent(), + int? imageWidth, + int? imageHeight, + int? boundingBoxX1, + int? boundingBoxY1, + int? boundingBoxX2, + int? boundingBoxY2, + String? sourceType, + }) => AssetFaceEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId.present ? personId.value : this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + ); + AssetFaceEntityData copyWithCompanion(AssetFaceEntityCompanion data) { + return AssetFaceEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + personId: data.personId.present ? data.personId.value : this.personId, + imageWidth: data.imageWidth.present + ? data.imageWidth.value + : this.imageWidth, + imageHeight: data.imageHeight.present + ? data.imageHeight.value + : this.imageHeight, + boundingBoxX1: data.boundingBoxX1.present + ? data.boundingBoxX1.value + : this.boundingBoxX1, + boundingBoxY1: data.boundingBoxY1.present + ? data.boundingBoxY1.value + : this.boundingBoxY1, + boundingBoxX2: data.boundingBoxX2.present + ? data.boundingBoxX2.value + : this.boundingBoxX2, + boundingBoxY2: data.boundingBoxY2.present + ? data.boundingBoxY2.value + : this.boundingBoxY2, + sourceType: data.sourceType.present + ? data.sourceType.value + : this.sourceType, + ); + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AssetFaceEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.personId == this.personId && + other.imageWidth == this.imageWidth && + other.imageHeight == this.imageHeight && + other.boundingBoxX1 == this.boundingBoxX1 && + other.boundingBoxY1 == this.boundingBoxY1 && + other.boundingBoxX2 == this.boundingBoxX2 && + other.boundingBoxY2 == this.boundingBoxY2 && + other.sourceType == this.sourceType); +} + +class AssetFaceEntityCompanion extends UpdateCompanion { + final Value id; + final Value assetId; + final Value personId; + final Value imageWidth; + final Value imageHeight; + final Value boundingBoxX1; + final Value boundingBoxY1; + final Value boundingBoxX2; + final Value boundingBoxY2; + final Value sourceType; + const AssetFaceEntityCompanion({ + this.id = const Value.absent(), + this.assetId = const Value.absent(), + this.personId = const Value.absent(), + this.imageWidth = const Value.absent(), + this.imageHeight = const Value.absent(), + this.boundingBoxX1 = const Value.absent(), + this.boundingBoxY1 = const Value.absent(), + this.boundingBoxX2 = const Value.absent(), + this.boundingBoxY2 = const Value.absent(), + this.sourceType = const Value.absent(), + }); + AssetFaceEntityCompanion.insert({ + required String id, + required String assetId, + this.personId = const Value.absent(), + required int imageWidth, + required int imageHeight, + required int boundingBoxX1, + required int boundingBoxY1, + required int boundingBoxX2, + required int boundingBoxY2, + required String sourceType, + }) : id = Value(id), + assetId = Value(assetId), + imageWidth = Value(imageWidth), + imageHeight = Value(imageHeight), + boundingBoxX1 = Value(boundingBoxX1), + boundingBoxY1 = Value(boundingBoxY1), + boundingBoxX2 = Value(boundingBoxX2), + boundingBoxY2 = Value(boundingBoxY2), + sourceType = Value(sourceType); + static Insertable custom({ + Expression? id, + Expression? assetId, + Expression? personId, + Expression? imageWidth, + Expression? imageHeight, + Expression? boundingBoxX1, + Expression? boundingBoxY1, + Expression? boundingBoxX2, + Expression? boundingBoxY2, + Expression? sourceType, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (personId != null) 'person_id': personId, + if (imageWidth != null) 'image_width': imageWidth, + if (imageHeight != null) 'image_height': imageHeight, + if (boundingBoxX1 != null) 'bounding_box_x1': boundingBoxX1, + if (boundingBoxY1 != null) 'bounding_box_y1': boundingBoxY1, + if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2, + if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2, + if (sourceType != null) 'source_type': sourceType, + }); + } + + AssetFaceEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? personId, + Value? imageWidth, + Value? imageHeight, + Value? boundingBoxX1, + Value? boundingBoxY1, + Value? boundingBoxX2, + Value? boundingBoxY2, + Value? sourceType, + }) { + return AssetFaceEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId ?? this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (personId.present) { + map['person_id'] = Variable(personId.value); + } + if (imageWidth.present) { + map['image_width'] = Variable(imageWidth.value); + } + if (imageHeight.present) { + map['image_height'] = Variable(imageHeight.value); + } + if (boundingBoxX1.present) { + map['bounding_box_x1'] = Variable(boundingBoxX1.value); + } + if (boundingBoxY1.present) { + map['bounding_box_y1'] = Variable(boundingBoxY1.value); + } + if (boundingBoxX2.present) { + map['bounding_box_x2'] = Variable(boundingBoxX2.value); + } + if (boundingBoxY2.present) { + map['bounding_box_y2'] = Variable(boundingBoxY2.value); + } + if (sourceType.present) { + map['source_type'] = Variable(sourceType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType') + ..write(')')) + .toString(); + } +} + +class StoreEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StoreEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stringValue = GeneratedColumn( + 'string_value', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn intValue = GeneratedColumn( + 'int_value', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [id, stringValue, intValue]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'store_entity'; + @override + Set get $primaryKey => {id}; + @override + StoreEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StoreEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + stringValue: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}string_value'], + ), + intValue: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}int_value'], + ), + ); + } + + @override + StoreEntity createAlias(String alias) { + return StoreEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StoreEntityData extends DataClass implements Insertable { + final int id; + final String? stringValue; + final int? intValue; + const StoreEntityData({required this.id, this.stringValue, this.intValue}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || stringValue != null) { + map['string_value'] = Variable(stringValue); + } + if (!nullToAbsent || intValue != null) { + map['int_value'] = Variable(intValue); + } + return map; + } + + factory StoreEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StoreEntityData( + id: serializer.fromJson(json['id']), + stringValue: serializer.fromJson(json['stringValue']), + intValue: serializer.fromJson(json['intValue']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'stringValue': serializer.toJson(stringValue), + 'intValue': serializer.toJson(intValue), + }; + } + + StoreEntityData copyWith({ + int? id, + Value stringValue = const Value.absent(), + Value intValue = const Value.absent(), + }) => StoreEntityData( + id: id ?? this.id, + stringValue: stringValue.present ? stringValue.value : this.stringValue, + intValue: intValue.present ? intValue.value : this.intValue, + ); + StoreEntityData copyWithCompanion(StoreEntityCompanion data) { + return StoreEntityData( + id: data.id.present ? data.id.value : this.id, + stringValue: data.stringValue.present + ? data.stringValue.value + : this.stringValue, + intValue: data.intValue.present ? data.intValue.value : this.intValue, + ); + } + + @override + String toString() { + return (StringBuffer('StoreEntityData(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, stringValue, intValue); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StoreEntityData && + other.id == this.id && + other.stringValue == this.stringValue && + other.intValue == this.intValue); +} + +class StoreEntityCompanion extends UpdateCompanion { + final Value id; + final Value stringValue; + final Value intValue; + const StoreEntityCompanion({ + this.id = const Value.absent(), + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }); + StoreEntityCompanion.insert({ + required int id, + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }) : id = Value(id); + static Insertable custom({ + Expression? id, + Expression? stringValue, + Expression? intValue, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (stringValue != null) 'string_value': stringValue, + if (intValue != null) 'int_value': intValue, + }); + } + + StoreEntityCompanion copyWith({ + Value? id, + Value? stringValue, + Value? intValue, + }) { + return StoreEntityCompanion( + id: id ?? this.id, + stringValue: stringValue ?? this.stringValue, + intValue: intValue ?? this.intValue, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (stringValue.present) { + map['string_value'] = Variable(stringValue.value); + } + if (intValue.present) { + map['int_value'] = Variable(intValue.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StoreEntityCompanion(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } +} + +class TrashedLocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TrashedLocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'trashed_local_asset_entity'; + @override + Set get $primaryKey => {id, albumId}; + @override + TrashedLocalAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TrashedLocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + ); + } + + @override + TrashedLocalAssetEntity createAlias(String alias) { + return TrashedLocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class TrashedLocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String albumId; + final String? checksum; + final bool isFavorite; + final int orientation; + final int source; + const TrashedLocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.albumId, + this.checksum, + required this.isFavorite, + required this.orientation, + required this.source, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + map['source'] = Variable(source); + return map; + } + + factory TrashedLocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TrashedLocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + albumId: serializer.fromJson(json['albumId']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + source: serializer.fromJson(json['source']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'albumId': serializer.toJson(albumId), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + 'source': serializer.toJson(source), + }; + } + + TrashedLocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? albumId, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + int? source, + }) => TrashedLocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + source: source ?? this.source, + ); + TrashedLocalAssetEntityData copyWithCompanion( + TrashedLocalAssetEntityCompanion data, + ) { + return TrashedLocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + source: data.source.present ? data.source.value : this.source, + ); + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('source: $source') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TrashedLocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.albumId == this.albumId && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation && + other.source == this.source); +} + +class TrashedLocalAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value albumId; + final Value checksum; + final Value isFavorite; + final Value orientation; + final Value source; + const TrashedLocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.albumId = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.source = const Value.absent(), + }); + TrashedLocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String albumId, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + required int source, + }) : name = Value(name), + type = Value(type), + id = Value(id), + albumId = Value(albumId), + source = Value(source); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? albumId, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + Expression? source, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (albumId != null) 'album_id': albumId, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + if (source != null) 'source': source, + }); + } + + TrashedLocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? albumId, + Value? checksum, + Value? isFavorite, + Value? orientation, + Value? source, + }) { + return TrashedLocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + source: source ?? this.source, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('source: $source') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV19 extends GeneratedDatabase { + DatabaseAtV19(QueryExecutor e) : super(e); + late final UserEntity userEntity = UserEntity(this); + late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); + late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final RemoteAlbumEntity remoteAlbumEntity = RemoteAlbumEntity(this); + late final LocalAlbumEntity localAlbumEntity = LocalAlbumEntity(this); + late final LocalAlbumAssetEntity localAlbumAssetEntity = + LocalAlbumAssetEntity(this); + late final Index idxLocalAlbumAssetAlbumAsset = Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAlbumOwnerId = Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + late final Index idxLocalAssetChecksum = Index( + 'idx_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)', + ); + late final Index idxLocalAssetCloudId = Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', + ); + late final Index idxStackPrimaryAssetId = Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', + ); + late final Index idxRemoteAssetOwnerChecksum = Index( + 'idx_remote_asset_owner_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', + ); + late final Index uQRemoteAssetsOwnerChecksum = Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', + ); + late final Index uQRemoteAssetsOwnerLibraryChecksum = Index( + 'UQ_remote_assets_owner_library_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', + ); + late final Index idxRemoteAssetChecksum = Index( + 'idx_remote_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', + ); + late final Index idxRemoteAssetStackId = Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + late final Index idxRemoteAssetLocalDateTimeDay = Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + late final Index idxRemoteAssetLocalDateTimeMonth = Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + late final AuthUserEntity authUserEntity = AuthUserEntity(this); + late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); + late final PartnerEntity partnerEntity = PartnerEntity(this); + late final RemoteExifEntity remoteExifEntity = RemoteExifEntity(this); + late final RemoteAlbumAssetEntity remoteAlbumAssetEntity = + RemoteAlbumAssetEntity(this); + late final RemoteAlbumUserEntity remoteAlbumUserEntity = + RemoteAlbumUserEntity(this); + late final RemoteAssetCloudIdEntity remoteAssetCloudIdEntity = + RemoteAssetCloudIdEntity(this); + late final MemoryEntity memoryEntity = MemoryEntity(this); + late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final PersonEntity personEntity = PersonEntity(this); + late final AssetFaceEntity assetFaceEntity = AssetFaceEntity(this); + late final StoreEntity storeEntity = StoreEntity(this); + late final TrashedLocalAssetEntity trashedLocalAssetEntity = + TrashedLocalAssetEntity(this); + late final Index idxPartnerSharedWithId = Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + late final Index idxLatLng = Index( + 'idx_lat_lng', + 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', + ); + late final Index idxRemoteAlbumAssetAlbumAsset = Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAssetCloudId = Index( + 'idx_remote_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)', + ); + late final Index idxPersonOwnerId = Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + late final Index idxAssetFacePersonId = Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + late final Index idxAssetFaceAssetId = Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', + ); + late final Index idxTrashedLocalAssetChecksum = Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', + ); + late final Index idxTrashedLocalAssetAlbum = Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + ]; + @override + int get schemaVersion => 19; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index 4152155d24..01ae50b6c4 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -637,6 +637,185 @@ void main() { }); }); + group('setProfilePicture button', () { + test('should show when owner, not locked, and asset is RemoteAsset', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + + test('should not show when asset is not RemoteAsset', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + }); + + group('setAlbumCover button', () { + test('should show when owner, not locked, has album, and selectedCount is 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when no current album', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when selectedCount is not 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 0, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when selectedCount is greater than 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 2, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + }); + group('likeActivity button', () { test('should show when not locked, has album, activity enabled, and shared', () { final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); @@ -846,6 +1025,21 @@ void main() { ); final widget = buttonType.buildButton(contextWithAlbum); expect(widget, isA()); + } else if (buttonType == ActionButtonType.setAlbumCover) { + final album = createRemoteAlbum(); + final contextWithAlbum = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + final widget = buttonType.buildButton(contextWithAlbum); + expect(widget, isA()); } else if (buttonType == ActionButtonType.unstack) { final album = createRemoteAlbum(); final contextWithAlbum = ActionButtonContext( diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 9aaec0ccca..ba6deefc30 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5342,7 +5342,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/DownloadArchiveDto" } } }, @@ -11529,6 +11529,78 @@ "x-immich-state": "Stable" } }, + "/shared-links/login": { + "post": { + "description": "Login to a password protected shared link", + "operationId": "sharedLinkLogin", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkLoginDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Shared link login", + "tags": [ + "Shared links" + ], + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Beta" + } + ], + "x-immich-state": "Beta" + } + }, "/shared-links/me": { "get": { "description": "Retrieve the current shared link associated with authentication method.", @@ -16339,7 +16411,15 @@ { "$ref": "#/components/schemas/AssetEditActionMirror" } - ] + ], + "discriminator": { + "mapping": { + "crop": "#/components/schemas/AssetEditActionCrop", + "mirror": "#/components/schemas/AssetEditActionMirror", + "rotate": "#/components/schemas/AssetEditActionRotate" + }, + "propertyName": "action" + } }, "minItems": 1, "type": "array" @@ -16410,7 +16490,15 @@ { "$ref": "#/components/schemas/AssetEditActionMirror" } - ] + ], + "discriminator": { + "mapping": { + "crop": "#/components/schemas/AssetEditActionCrop", + "mirror": "#/components/schemas/AssetEditActionMirror", + "rotate": "#/components/schemas/AssetEditActionRotate" + }, + "propertyName": "action" + } }, "minItems": 1, "type": "array" @@ -17884,6 +17972,26 @@ }, "type": "object" }, + "DownloadArchiveDto": { + "properties": { + "assetIds": { + "description": "Asset IDs", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "edited": { + "description": "Download edited asset if available", + "type": "boolean" + } + }, + "required": [ + "assetIds" + ], + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -22071,6 +22179,19 @@ }, "type": "object" }, + "SharedLinkLoginDto": { + "properties": { + "password": { + "description": "Shared link password", + "example": "password", + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, "SharedLinkResponseDto": { "properties": { "album": { @@ -22129,9 +22250,25 @@ "type": "string" }, "token": { + "deprecated": true, "description": "Access token", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "type": { "allOf": [ diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 6310316857..8f057df6cc 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "typescript": "^5.3.3" }, "repository": { @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 4b78c17adc..ef60d90b6c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1146,9 +1146,11 @@ export type ValidateAccessTokenResponseDto = { /** Authentication status */ authStatus: boolean; }; -export type AssetIdsDto = { +export type DownloadArchiveDto = { /** Asset IDs */ assetIds: string[]; + /** Download edited asset if available */ + edited?: boolean; }; export type DownloadInfoDto = { /** Album ID to download */ @@ -2302,6 +2304,10 @@ export type SharedLinkCreateDto = { /** Shared link type */ "type": SharedLinkType; }; +export type SharedLinkLoginDto = { + /** Shared link password */ + password: string; +}; export type SharedLinkEditDto = { /** Allow downloads */ allowDownload?: boolean; @@ -2320,6 +2326,10 @@ export type SharedLinkEditDto = { /** Custom URL slug */ slug?: string | null; }; +export type AssetIdsDto = { + /** Asset IDs */ + assetIds: string[]; +}; export type AssetIdsResponseDto = { /** Asset ID */ assetId: string; @@ -4528,10 +4538,10 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { /** * Download asset archive */ -export function downloadArchive({ key, slug, assetIdsDto }: { +export function downloadArchive({ key, slug, downloadArchiveDto }: { key?: string; slug?: string; - assetIdsDto: AssetIdsDto; + downloadArchiveDto: DownloadArchiveDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; @@ -4542,7 +4552,7 @@ export function downloadArchive({ key, slug, assetIdsDto }: { }))}`, oazapfts.json({ ...opts, method: "POST", - body: assetIdsDto + body: downloadArchiveDto }))); } /** @@ -5960,6 +5970,26 @@ export function createSharedLink({ sharedLinkCreateDto }: { body: sharedLinkCreateDto }))); } +/** + * Shared link login + */ +export function sharedLinkLogin({ key, slug, sharedLinkLoginDto }: { + key?: string; + slug?: string; + sharedLinkLoginDto: SharedLinkLoginDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: SharedLinkResponseDto; + }>(`/shared-links/login${QS.query(QS.explode({ + key, + slug + }))}`, oazapfts.json({ + ...opts, + method: "POST", + body: sharedLinkLoginDto + }))); +} /** * Retrieve current shared link */ diff --git a/package.json b/package.json index 706b4b02bc..c50c4e1eb8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.5.6", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48", + "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "engines": { "pnpm": ">=10.0.0" } diff --git a/plugins/mise.toml b/plugins/mise.toml index c1001e574b..66a107674d 100644 --- a/plugins/mise.toml +++ b/plugins/mise.toml @@ -1,7 +1,7 @@ [tools] "github:extism/cli" = "1.6.3" "github:webassembly/binaryen" = "version_124" -"github:extism/js-pdk" = "1.5.1" +"github:extism/js-pdk" = "1.6.0" [tasks.install] run = "pnpm install --frozen-lockfile" diff --git a/plugins/package-lock.json b/plugins/package-lock.json index 1d8b9cb1ad..9ebaa59a02 100644 --- a/plugins/package-lock.json +++ b/plugins/package-lock.json @@ -15,9 +15,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -32,9 +32,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -49,9 +49,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -66,9 +66,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -83,9 +83,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -100,9 +100,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -117,9 +117,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -151,9 +151,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -168,9 +168,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -185,9 +185,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -202,9 +202,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -219,9 +219,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -236,9 +236,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -253,9 +253,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -270,9 +270,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -287,9 +287,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -304,9 +304,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -321,9 +321,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -338,9 +338,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -355,9 +355,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -372,9 +372,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -389,9 +389,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -406,9 +406,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -423,9 +423,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -440,9 +440,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -467,9 +467,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -480,32 +480,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/typescript": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8325bd3f89..0e8f0c84b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54= -pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0= +pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA= importers: @@ -21,7 +21,7 @@ importers: devDependencies: prettier: specifier: ^3.7.4 - version: 3.8.0 + version: 3.8.1 cli: dependencies: @@ -45,7 +45,7 @@ importers: specifier: ^9.8.0 version: 9.39.2 '@immich/sdk': - specifier: file:../open-api/typescript-sdk + specifier: workspace:* version: link:../open-api/typescript-sdk '@types/byte-size': specifier: ^8.1.0 @@ -63,11 +63,11 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -85,7 +85,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.0) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -97,28 +97,28 @@ importers: version: 5.5.0 prettier: specifier: ^3.7.4 - version: 3.8.0 + version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.0.0 - version: 4.3.0(prettier@3.8.0)(typescript@5.9.3) + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) typescript: specifier: ^5.3.3 version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -127,16 +127,16 @@ importers: dependencies: '@docusaurus/core': specifier: ~3.9.0 - version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/preset-classic': specifier: ~3.9.0 - version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/theme-common': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-mermaid': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@mdi/js': specifier: ^7.3.67 version: 7.4.47 @@ -145,13 +145,13 @@ importers: version: 1.6.1 '@mdx-js/react': specifier: ^3.0.0 - version: 3.1.1(@types/react@19.2.8)(react@18.3.1) + version: 3.1.1(@types/react@19.2.14)(react@18.3.1) autoprefixer: specifier: ^10.4.17 - version: 10.4.23(postcss@8.5.6) + version: 10.4.24(postcss@8.5.6) docusaurus-lunr-search: specifier: ^3.3.2 - version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lunr: specifier: ^2.3.9 version: 2.3.9 @@ -188,7 +188,7 @@ importers: version: 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) prettier: specifier: ^3.7.4 - version: 3.8.0 + version: 3.8.1 typescript: specifier: ^5.1.6 version: 5.9.3 @@ -200,19 +200,19 @@ importers: version: 9.39.2 '@faker-js/faker': specifier: ^10.1.0 - version: 10.2.0 + version: 10.3.0 '@immich/cli': - specifier: file:../cli + specifier: workspace:* version: link:../cli '@immich/e2e-auth-server': - specifier: file:../e2e-auth-server + specifier: workspace:* version: link:../e2e-auth-server '@immich/sdk': - specifier: file:../open-api/typescript-sdk + specifier: workspace:* version: link:../open-api/typescript-sdk '@playwright/test': specifier: ^1.44.1 - version: 1.57.0 + version: 1.58.2 '@socket.io/component-emitter': specifier: ^3.1.2 version: 3.1.2 @@ -220,7 +220,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/pg': specifier: ^8.15.1 @@ -233,7 +233,7 @@ importers: version: 6.0.3 dotenv: specifier: ^17.2.3 - version: 17.2.3 + version: 17.2.4 eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -242,7 +242,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.0) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -257,16 +257,16 @@ importers: version: 3.7.2 pg: specifier: ^8.11.3 - version: 8.17.1 + version: 8.18.0 pngjs: specifier: ^7.0.0 version: 7.0.0 prettier: specifier: ^3.7.4 - version: 3.8.0 + version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.0.0 - version: 4.3.0(prettier@3.8.0)(typescript@5.9.3) + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -281,13 +281,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) utimes: specifier: ^5.2.1 version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -308,19 +308,19 @@ importers: devDependencies: prettier: specifier: ^3.7.4 - version: 3.8.0 + version: 3.8.1 prettier-plugin-sort-json: specifier: ^4.1.1 - version: 4.2.0(prettier@3.8.0) + version: 4.2.0(prettier@3.8.1) open-api/typescript-sdk: dependencies: '@oazapfts/runtime': specifier: ^1.0.2 - version: 1.1.0 + version: 1.2.0 devDependencies: '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 typescript: specifier: ^5.3.3 @@ -333,7 +333,7 @@ importers: version: 1.1.1 esbuild: specifier: ^0.27.0 - version: 0.27.2 + version: 0.27.3 typescript: specifier: ^5.3.2 version: 5.9.3 @@ -345,73 +345,73 @@ importers: version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(bullmq@5.66.5) + version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.4 - version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) + version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) '@nestjs/platform-socket.io': specifier: ^11.0.4 - version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2) + version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.0.0 - version: 6.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) + version: 6.1.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) '@nestjs/swagger': specifier: ^11.0.2 - version: 11.2.5(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + version: 11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-socket.io@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 '@opentelemetry/context-async-hooks': specifier: ^2.0.0 - version: 2.4.0(@opentelemetry/api@1.9.0) + version: 2.5.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-prometheus': - specifier: ^0.210.0 - version: 0.210.0(@opentelemetry/api@1.9.0) + specifier: ^0.211.0 + version: 0.211.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-http': - specifier: ^0.210.0 - version: 0.210.0(@opentelemetry/api@1.9.0) + specifier: ^0.211.0 + version: 0.211.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-ioredis': - specifier: ^0.58.0 - version: 0.58.0(@opentelemetry/api@1.9.0) + specifier: ^0.59.0 + version: 0.59.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-nestjs-core': - specifier: ^0.56.0 - version: 0.56.0(@opentelemetry/api@1.9.0) + specifier: ^0.57.0 + version: 0.57.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-pg': - specifier: ^0.62.0 - version: 0.62.0(@opentelemetry/api@1.9.0) + specifier: ^0.63.0 + version: 0.63.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': specifier: ^2.0.1 - version: 2.4.0(@opentelemetry/api@1.9.0) + version: 2.5.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': specifier: ^2.0.1 - version: 2.4.0(@opentelemetry/api@1.9.0) + version: 2.5.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': - specifier: ^0.210.0 - version: 0.210.0(@opentelemetry/api@1.9.0) + specifier: ^0.211.0 + version: 0.211.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: ^1.34.0 - version: 1.38.0 + version: 1.39.0 '@react-email/components': specifier: ^0.5.0 - version: 0.5.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 0.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-email/render': specifier: ^1.1.2 - version: 1.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.6) ajv: specifier: ^8.17.1 - version: 8.17.1 + version: 8.18.0 archiver: specifier: ^7.0.0 version: 7.0.1 @@ -426,7 +426,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.66.5 + version: 5.68.0 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -446,8 +446,8 @@ importers: specifier: ^1.4.7 version: 1.4.7 cron: - specifier: 4.3.5 - version: 4.3.5 + specifier: 4.4.0 + version: 4.4.0 exiftool-vendored: specifier: ^34.3.0 version: 34.3.0 @@ -471,7 +471,7 @@ importers: version: 7.14.0 ioredis: specifier: ^5.8.2 - version: 5.9.1 + version: 5.9.2 jose: specifier: ^5.10.0 version: 5.10.0 @@ -501,28 +501,28 @@ importers: version: 2.0.2 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@types/inquirer@8.2.12)(@types/node@24.10.13)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@types/inquirer@8.2.12)(@types/node@24.10.13)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 - version: 5.4.3(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 5.4.3(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(kysely@0.28.2)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(kysely@0.28.2)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^7.0.0 - version: 7.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) + version: 7.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) nodemailer: specifier: ^7.0.0 - version: 7.0.12 + version: 7.0.13 openid-client: specifier: ^6.3.3 - version: 6.8.1 + version: 6.8.2 pg: specifier: ^8.11.3 - version: 8.17.1 + version: 8.18.0 pg-connection-string: specifier: ^2.9.1 - version: 2.10.0 + version: 2.11.0 picomatch: specifier: ^4.0.2 version: 4.0.3 @@ -531,10 +531,10 @@ importers: version: 3.4.8 react: specifier: ^19.0.0 - version: 19.2.3 + version: 19.2.4 react-dom: specifier: ^19.0.0 - version: 19.2.3(react@19.2.3) + version: 19.2.4(react@19.2.4) react-email: specifier: ^4.0.0 version: 4.3.2 @@ -552,7 +552,7 @@ importers: version: 2.17.0 semver: specifier: ^7.6.2 - version: 7.7.3 + version: 7.7.4 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -573,7 +573,7 @@ importers: version: 3.1.0 ua-parser-js: specifier: ^2.0.0 - version: 2.0.8 + version: 2.0.9 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -586,16 +586,16 @@ importers: version: 9.39.2 '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.15(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@24.10.13) + version: 11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.4 - version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-express@11.1.12) + version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-express@11.1.13) '@swc/core': specifier: ^1.4.14 - version: 1.15.8(@swc/helpers@0.5.17) + version: 1.15.11(@swc/helpers@0.5.17) '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -639,11 +639,11 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/nodemailer': specifier: ^7.0.0 - version: 7.0.5 + version: 7.0.9 '@types/picomatch': specifier: ^4.0.0 version: 4.0.2 @@ -652,7 +652,7 @@ importers: version: 6.0.5 '@types/react': specifier: ^19.0.0 - version: 19.2.8 + version: 19.2.14 '@types/sanitize-html': specifier: ^2.13.0 version: 2.16.0 @@ -670,7 +670,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -679,7 +679,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.0) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -691,16 +691,16 @@ importers: version: 5.5.0 node-gyp: specifier: ^12.0.0 - version: 12.1.0 + version: 12.2.0 pngjs: specifier: ^7.0.0 version: 7.0.0 prettier: specifier: ^3.7.4 - version: 3.8.0 + version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.0.0 - version: 4.3.0(prettier@3.8.0)(typescript@5.9.3) + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) sql-formatter: specifier: ^15.0.0 version: 15.7.0 @@ -718,34 +718,34 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 - version: 1.5.9(@swc/core@1.15.8(@swc/helpers@0.5.17))(rollup@4.55.1) + version: 1.5.9(@swc/core@1.15.11(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: '@formatjs/icu-messageformat-parser': specifier: ^3.0.0 - version: 3.3.0 + version: 3.5.1 '@immich/justified-layout-wasm': specifier: ^0.4.3 version: 0.4.3 '@immich/sdk': - specifier: file:../open-api/typescript-sdk + specifier: workspace:* version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.62.0 - version: 0.62.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) + specifier: ^0.64.0 + version: 0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) '@mapbox/mapbox-gl-rtl-text': - specifier: 0.2.3 - version: 0.2.3(mapbox-gl@1.13.3) + specifier: 0.3.0 + version: 0.3.0 '@mdi/js': specifier: ^7.4.47 version: 7.4.47 @@ -771,17 +771,17 @@ importers: specifier: ^7946.0.16 version: 7946.0.16 '@zoom-image/core': - specifier: ^0.41.0 - version: 0.41.4 + specifier: ^0.42.0 + version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.8(svelte@5.48.0) + version: 0.3.9(svelte@5.51.5) dom-to-image: specifier: ^2.6.0 version: 2.6.0 fabric: - specifier: ^6.5.4 - version: 6.9.1 + specifier: ^7.0.0 + version: 7.2.0 geo-coordinates-parser: specifier: ^1.7.4 version: 1.7.4 @@ -793,10 +793,10 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.3.0 + version: 20.6.1 intl-messageformat: specifier: ^11.0.0 - version: 11.0.9 + version: 11.1.2 justified-layout: specifier: ^4.1.0 version: 4.1.0 @@ -808,10 +808,10 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.16.0 + version: 5.18.0 pmtiles: specifier: ^4.3.0 - version: 4.3.2 + version: 4.4.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -826,16 +826,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.48.0) + version: 4.0.1(svelte@5.51.5) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.48.0) + version: 3.11.0(svelte@5.51.5) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.5(svelte@5.48.0) + version: 1.2.6(svelte@5.51.5) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.48.0) + version: 0.12.0(svelte@5.51.5) tabbable: specifier: ^6.2.0 version: 6.4.0 @@ -854,7 +854,7 @@ importers: version: 9.39.2 '@faker-js/faker': specifier: ^10.0.0 - version: 10.2.0 + version: 10.3.0 '@koddsson/eslint-plugin-tscompat': specifier: ^0.2.0 version: 0.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -863,25 +863,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': - specifier: ^0.9.0 - version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + specifier: ^0.10.0 + version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -905,10 +905,10 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 - version: 17.2.3 + version: 17.2.4 eslint: specifier: ^9.36.0 version: 9.39.2(jiti@2.6.1) @@ -917,10 +917,10 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-compat: specifier: ^6.0.2 - version: 6.0.2(eslint@9.39.2(jiti@2.6.1)) + version: 6.1.0(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0) + version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.51.5) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -932,28 +932,28 @@ importers: version: 16.5.0 prettier: specifier: ^3.7.4 - version: 3.8.0 + version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.0.0 - version: 4.3.0(prettier@3.8.0)(typescript@5.9.3) + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) prettier-plugin-sort-json: specifier: ^4.1.1 - version: 4.2.0(prettier@3.8.0) + version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.1(prettier@3.8.0)(svelte@5.48.0) + version: 3.4.1(prettier@3.8.1)(svelte@5.51.5) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.48.0 - version: 5.48.0 + specifier: 5.51.5 + version: 5.51.5 svelte-check: specifier: ^4.1.5 - version: 4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3) + version: 4.3.6(picomatch@4.0.3)(svelte@5.51.5)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.48.0) + version: 1.4.1(svelte@5.51.5) tailwindcss: specifier: ^4.1.7 version: 4.1.18 @@ -962,13 +962,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -1119,137 +1119,8 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/client-sesv2@3.971.0': - resolution: {integrity: sha512-NP/lbf3mfY10Txzl0ml2YnTjnZwflp1+faOotMCrXi4fb6kInosdW0ZSHXNlNulFo9cW+llq07lD59Sw3nny+A==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-sso@3.971.0': - resolution: {integrity: sha512-Xx+w6DQqJxDdymYyIxyKJnRzPvVJ4e/Aw0czO7aC9L/iraaV7AG8QtRe93OGW6aoHSh72CIiinnpJJfLsQqP4g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.970.0': - resolution: {integrity: sha512-klpzObldOq8HXzDjDlY6K8rMhYZU6mXRz6P9F9N+tWnjoYFfeBMra8wYApydElTUYQKP1O7RLHwH1OKFfKcqIA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-env@3.970.0': - resolution: {integrity: sha512-rtVzXzEtAfZBfh+lq3DAvRar4c3jyptweOAJR2DweyXx71QSMY+O879hjpMwES7jl07a3O1zlnFIDo4KP/96kQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-http@3.970.0': - resolution: {integrity: sha512-CjDbWL7JxjLc9ZxQilMusWSw05yRvUJKRpz59IxDpWUnSMHC9JMMUUkOy5Izk8UAtzi6gupRWArp4NG4labt9Q==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-ini@3.971.0': - resolution: {integrity: sha512-c0TGJG4xyfTZz3SInXfGU8i5iOFRrLmy4Bo7lMyH+IpngohYMYGYl61omXqf2zdwMbDv+YJ9AviQTcCaEUKi8w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.971.0': - resolution: {integrity: sha512-yhbzmDOsk0RXD3rTPhZra4AWVnVAC4nFWbTp+sUty1hrOPurUmhuz8bjpLqYTHGnlMbJp+UqkQONhS2+2LzW2g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-node@3.971.0': - resolution: {integrity: sha512-epUJBAKivtJqalnEBRsYIULKYV063o/5mXNJshZfyvkAgNIzc27CmmKRXTN4zaNOZg8g/UprFp25BGsi19x3nQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-process@3.970.0': - resolution: {integrity: sha512-0XeT8OaT9iMA62DFV9+m6mZfJhrD0WNKf4IvsIpj2Z7XbaYfz3CoDDvNoALf3rPY9NzyMHgDxOspmqdvXP00mw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-sso@3.971.0': - resolution: {integrity: sha512-dY0hMQ7dLVPQNJ8GyqXADxa9w5wNfmukgQniLxGVn+dMRx3YLViMp5ZpTSQpFhCWNF0oKQrYAI5cHhUJU1hETw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-web-identity@3.971.0': - resolution: {integrity: sha512-F1AwfNLr7H52T640LNON/h34YDiMuIqW/ZreGzhRR6vnFGaSPtNSKAKB2ssAMkLM8EVg8MjEAYD3NCUiEo+t/w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-host-header@3.969.0': - resolution: {integrity: sha512-AWa4rVsAfBR4xqm7pybQ8sUNJYnjyP/bJjfAw34qPuh3M9XrfGbAHG0aiAfQGrBnmS28jlO6Kz69o+c6PRw1dw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-logger@3.969.0': - resolution: {integrity: sha512-xwrxfip7Y2iTtCMJ+iifN1E1XMOuhxIHY9DreMCvgdl4r7+48x2S1bCYPWH3eNY85/7CapBWdJ8cerpEl12sQQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-recursion-detection@3.969.0': - resolution: {integrity: sha512-2r3PuNquU3CcS1Am4vn/KHFwLi8QFjMdA/R+CRDXT4AFO/0qxevF/YStW3gAKntQIgWgQV8ZdEtKAoJvLI4UWg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-sdk-s3@3.970.0': - resolution: {integrity: sha512-v/Y5F1lbFFY7vMeG5yYxuhnn0CAshz6KMxkz1pDyPxejNE9HtA0w8R6OTBh/bVdIm44QpjhbI7qeLdOE/PLzXQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-user-agent@3.970.0': - resolution: {integrity: sha512-dnSJGGUGSFGEX2NzvjwSefH+hmZQ347AwbLhAsi0cdnISSge+pcGfOFrJt2XfBIypwFe27chQhlfuf/gWdzpZg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.971.0': - resolution: {integrity: sha512-TWaILL8GyYlhGrxxnmbkazM4QsXatwQgoWUvo251FXmUOsiXDFDVX3hoGIfB3CaJhV2pJPfebHUNJtY6TjZ11g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.969.0': - resolution: {integrity: sha512-scj9OXqKpcjJ4jsFLtqYWz3IaNvNOQTFFvEY8XMJXTv+3qF5I7/x9SJtKzTRJEBF3spjzBUYPtGFbs9sj4fisQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/signature-v4-multi-region@3.970.0': - resolution: {integrity: sha512-z3syXfuK/x/IsKf/AeYmgc2NT7fcJ+3fHaGO+fkghkV9WEba3fPyOwtTBX4KpFMNb2t50zDGZwbzW1/5ighcUQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/token-providers@3.971.0': - resolution: {integrity: sha512-4hKGWZbmuDdONMJV0HJ+9jwTDb0zLfKxcCLx2GEnBY31Gt9GeyIQ+DZ97Bb++0voawj6pnZToFikXTyrEq2x+w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.969.0': - resolution: {integrity: sha512-7IIzM5TdiXn+VtgPdVLjmE6uUBUtnga0f4RiSEI1WW10RPuNvZ9U+pL3SwDiRDAdoGrOF9tSLJOFZmfuwYuVYQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-arn-parser@3.968.0': - resolution: {integrity: sha512-gqqvYcitIIM2K4lrDX9de9YvOfXBcVdxfT/iLnvHJd4YHvSXlt+gs+AsL4FfPCxG4IG9A+FyulP9Sb1MEA75vw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.970.0': - resolution: {integrity: sha512-TZNZqFcMUtjvhZoZRtpEGQAdULYiy6rcGiXAbLU7e9LSpIYlRqpLa207oMNfgbzlL2PnHko+eVg8rajDiSOYCg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-locate-window@3.965.2': - resolution: {integrity: sha512-qKgO7wAYsXzhwCHhdbaKFyxd83Fgs8/1Ka+jjSPrv2Ll7mB55Wbwlo0kkfMLh993/yEc8aoDIAc1Fz9h4Spi4Q==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-user-agent-browser@3.969.0': - resolution: {integrity: sha512-bpJGjuKmFr0rA6UKUCmN8D19HQFMLXMx5hKBXqBlPFdalMhxJSjcxzX9DbQh0Fn6bJtxCguFmRGOBdQqNOt49g==} - - '@aws-sdk/util-user-agent-node@3.971.0': - resolution: {integrity: sha512-Eygjo9mFzQYjbGY3MYO6CsIhnTwAMd3WmuFalCykqEmj2r5zf0leWrhPaqvA5P68V5JdGfPYgj7vhNOd6CtRBQ==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.969.0': - resolution: {integrity: sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.2.3': - resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} - engines: {node: '>=18.0.0'} - - '@babel/code-frame@7.28.6': - resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} '@babel/compat-data@7.28.5': @@ -2396,8 +2267,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -2414,8 +2285,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -2432,8 +2303,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -2450,8 +2321,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -2468,8 +2339,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -2486,8 +2357,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -2504,8 +2375,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -2522,8 +2393,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -2540,8 +2411,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -2558,8 +2429,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -2576,8 +2447,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -2594,8 +2465,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -2612,8 +2483,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -2630,8 +2501,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -2648,8 +2519,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -2666,8 +2537,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -2684,8 +2555,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -2696,8 +2567,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -2714,8 +2585,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -2726,8 +2597,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -2744,8 +2615,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -2756,8 +2627,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -2774,8 +2645,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -2792,8 +2663,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -2810,8 +2681,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -2828,8 +2699,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2878,8 +2749,8 @@ packages: '@extism/js-pdk@1.1.1': resolution: {integrity: sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==} - '@faker-js/faker@10.2.0': - resolution: {integrity: sha512-rTXwAsIxpCqzUnZvrxVh3L0QA0NzToqWBLAhV+zDV3MIIwiQhAZHMdPCIaj5n/yADu/tyk12wIPgL6YHGXJP+g==} + '@faker-js/faker@10.3.0': + resolution: {integrity: sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} '@fig/complete-commander@3.2.0': @@ -2899,32 +2770,32 @@ packages: '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} - '@formatjs/ecma402-abstract@3.0.8': - resolution: {integrity: sha512-NRiqvxAvhbARZRFSRFPjN0y8txxmVutv2vMYvW2HSdCVf58w9l4osLj6Ujif643vImwZBcbKqhiKE0IOhY+DvA==} + '@formatjs/ecma402-abstract@3.1.1': + resolution: {integrity: sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==} '@formatjs/fast-memoize@2.2.7': resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} - '@formatjs/fast-memoize@3.0.3': - resolution: {integrity: sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q==} + '@formatjs/fast-memoize@3.1.0': + resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==} '@formatjs/icu-messageformat-parser@2.11.4': resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} - '@formatjs/icu-messageformat-parser@3.3.0': - resolution: {integrity: sha512-dqxGSwH22ZfBwa6EVvrrIo+8kHHUSjuw9iZy6HkkN5XgH5/8ny9zDGhvC6ZOFYp01PAbwHvUTIHqznC6Z1nIbA==} + '@formatjs/icu-messageformat-parser@3.5.1': + resolution: {integrity: sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==} '@formatjs/icu-skeleton-parser@1.8.16': resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} - '@formatjs/icu-skeleton-parser@2.0.8': - resolution: {integrity: sha512-Z493tGxtKu0xNcSZjS8HrWNfq25HMscqbq5qwRFBYz14b70k1DHmhqVAwYDdDK0Ytj9YG1nvY4+IRq53LVNFdA==} + '@formatjs/icu-skeleton-parser@2.1.1': + resolution: {integrity: sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==} '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} - '@formatjs/intl-localematcher@0.7.5': - resolution: {integrity: sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA==} + '@formatjs/intl-localematcher@0.8.1': + resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==} '@fortawesome/fontawesome-common-types@7.1.0': resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==} @@ -3016,89 +2887,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -3131,8 +3018,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.62.0': - resolution: {integrity: sha512-h3x5s+y/BndRBmWDnCay1jmqX1gbmHWRjkBVAjM/S9joeDnJOzN06mEsLkeuNF0xCzEYaSrIexYEsafPFwiwvQ==} + '@immich/ui@0.64.0': + resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==} peerDependencies: svelte: ^5.0.0 @@ -3140,10 +3027,6 @@ packages: resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} - '@inquirer/ansi@2.0.3': - resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/checkbox@4.3.2': resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} engines: {node: '>=18'} @@ -3153,15 +3036,6 @@ packages: '@types/node': optional: true - '@inquirer/checkbox@5.0.4': - resolution: {integrity: sha512-DrAMU3YBGMUAp6ArwTIp/25CNDtDbxk7UjIrrtM25JVVrlVYlVzHh5HR1BDFu9JMyUoZ4ZanzeaHqNDttf3gVg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/confirm@5.1.21': resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} engines: {node: '>=18'} @@ -3171,15 +3045,6 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.0.4': - resolution: {integrity: sha512-WdaPe7foUnoGYvXzH4jp4wH/3l+dBhZ3uwhKjXjwdrq5tEIFaANxj6zrGHxLdsIA0yKM0kFPVcEalOZXBB5ISA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/core@10.3.2': resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} engines: {node: '>=18'} @@ -3189,15 +3054,6 @@ packages: '@types/node': optional: true - '@inquirer/core@11.1.1': - resolution: {integrity: sha512-hV9o15UxX46OyQAtaoMqAOxGR8RVl1aZtDx1jHbCtSJy1tBdTfKxLPKf7utsE4cRy4tcmCQ4+vdV+ca+oNxqNA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/editor@4.2.23': resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} engines: {node: '>=18'} @@ -3207,15 +3063,6 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.0.4': - resolution: {integrity: sha512-QI3Jfqcv6UO2/VJaEFONH8Im1ll++Xn/AJTBn9Xf+qx2M+H8KZAdQ5sAe2vtYlo+mLW+d7JaMJB4qWtK4BG3pw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/expand@4.0.23': resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} engines: {node: '>=18'} @@ -3225,15 +3072,6 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.0.4': - resolution: {integrity: sha512-0I/16YwPPP0Co7a5MsomlZLpch48NzYfToyqYAOWtBmaXSB80RiNQ1J+0xx2eG+Wfxt0nHtpEWSRr6CzNVnOGg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -3243,23 +3081,10 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@2.0.3': - resolution: {integrity: sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/figures@1.0.15': resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} engines: {node: '>=18'} - '@inquirer/figures@2.0.3': - resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/input@4.3.1': resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} engines: {node: '>=18'} @@ -3269,15 +3094,6 @@ packages: '@types/node': optional: true - '@inquirer/input@5.0.4': - resolution: {integrity: sha512-4B3s3jvTREDFvXWit92Yc6jF1RJMDy2VpSqKtm4We2oVU65YOh2szY5/G14h4fHlyQdpUmazU5MPCFZPRJ0AOw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/number@3.0.23': resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} engines: {node: '>=18'} @@ -3287,15 +3103,6 @@ packages: '@types/node': optional: true - '@inquirer/number@4.0.4': - resolution: {integrity: sha512-CmMp9LF5HwE+G/xWsC333TlCzYYbXMkcADkKzcawh49fg2a1ryLc7JL1NJYYt1lJ+8f4slikNjJM9TEL/AljYQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/password@4.0.23': resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} engines: {node: '>=18'} @@ -3305,9 +3112,9 @@ packages: '@types/node': optional: true - '@inquirer/password@5.0.4': - resolution: {integrity: sha512-ZCEPyVYvHK4W4p2Gy6sTp9nqsdHQCfiPXIP9LbJVW4yCinnxL/dDDmPaEZVysGrj8vxVReRnpfS2fOeODe9zjg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -3323,15 +3130,6 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.2.0': - resolution: {integrity: sha512-rqTzOprAj55a27jctS3vhvDDJzYXsr33WXTjODgVOru21NvBo9yIgLIAf7SBdSV0WERVly3dR6TWyp7ZHkvKFA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/rawlist@4.1.11': resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} engines: {node: '>=18'} @@ -3341,15 +3139,6 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.2.0': - resolution: {integrity: sha512-CciqGoOUMrFo6HxvOtU5uL8fkjCmzyeB6fG7O1vdVAZVSopUBYECOwevDBlqNLyyYmzpm2Gsn/7nLrpruy9RFg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/search@3.2.2': resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} engines: {node: '>=18'} @@ -3359,15 +3148,6 @@ packages: '@types/node': optional: true - '@inquirer/search@4.1.0': - resolution: {integrity: sha512-EAzemfiP4IFvIuWnrHpgZs9lAhWDA0GM3l9F4t4mTQ22IFtzfrk8xbkMLcAN7gmVML9O/i+Hzu8yOUyAaL6BKA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/select@4.4.2': resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} engines: {node: '>=18'} @@ -3377,15 +3157,6 @@ packages: '@types/node': optional: true - '@inquirer/select@5.0.4': - resolution: {integrity: sha512-s8KoGpPYMEQ6WXc0dT9blX2NtIulMdLOO3LA1UKOiv7KFWzlJ6eLkEYTDBIi+JkyKXyn8t/CD6TinxGjyLt57g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/type@3.0.10': resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} engines: {node: '>=18'} @@ -3395,15 +3166,6 @@ packages: '@types/node': optional: true - '@inquirer/type@4.0.3': - resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@internationalized/date@3.10.0': resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==} @@ -3414,8 +3176,8 @@ packages: resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + '@isaacs/brace-expansion@5.0.1': + resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} engines: {node: 20 || >=22} '@isaacs/cliui@8.0.2': @@ -3548,48 +3310,26 @@ packages: resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} hasBin: true - '@mapbox/geojson-types@1.0.2': - resolution: {integrity: sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==} - '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} - '@mapbox/mapbox-gl-rtl-text@0.2.3': - resolution: {integrity: sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' - - '@mapbox/mapbox-gl-supported@1.5.0': - resolution: {integrity: sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' + '@mapbox/mapbox-gl-rtl-text@0.3.0': + resolution: {integrity: sha512-OwQplFqAAEYRobrTKm2wiVP+wcpUVlgXXiUMNQ8tcm5gPN5SQRXFADmITdQOaec4LhDhuuFchS7TS8ua8dUl4w==} '@mapbox/node-pre-gyp@1.0.11': resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true - '@mapbox/point-geometry@0.1.0': - resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} - '@mapbox/point-geometry@1.1.0': resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} - '@mapbox/tiny-sdf@1.2.5': - resolution: {integrity: sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==} - '@mapbox/tiny-sdf@2.0.7': resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} - '@mapbox/unitbezier@0.0.0': - resolution: {integrity: sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==} - '@mapbox/unitbezier@0.0.1': resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} - '@mapbox/vector-tile@1.3.1': - resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} - '@mapbox/vector-tile@2.0.4': resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} @@ -3597,15 +3337,18 @@ packages: resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} engines: {node: '>=6.0.0'} + '@maplibre/geojson-vt@5.0.4': + resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} + '@maplibre/maplibre-gl-style-spec@24.4.1': resolution: {integrity: sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==} hasBin: true - '@maplibre/mlt@1.1.2': - resolution: {integrity: sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==} + '@maplibre/mlt@1.1.6': + resolution: {integrity: sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==} - '@maplibre/vt-pbf@4.2.0': - resolution: {integrity: sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA==} + '@maplibre/vt-pbf@4.2.1': + resolution: {integrity: sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==} '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -3683,8 +3426,8 @@ packages: '@nestjs/core': ^10.0.0 || ^11.0.0 bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 - '@nestjs/cli@11.0.15': - resolution: {integrity: sha512-4Sw4i+PRI1CGVnl3F15GWytFYD+QHs6vsayVeqDhhWwL1a7ZhQyUYvmlCMoWi77rZA0+m3ObUO1WujtkXsYBDQ==} + '@nestjs/cli@11.0.16': + resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} engines: {node: '>= 20.11'} hasBin: true peerDependencies: @@ -3696,8 +3439,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.12': - resolution: {integrity: sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==} + '@nestjs/common@11.1.13': + resolution: {integrity: sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -3709,8 +3452,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.12': - resolution: {integrity: sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==} + '@nestjs/core@11.1.13': + resolution: {integrity: sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -3740,21 +3483,21 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.12': - resolution: {integrity: sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==} + '@nestjs/platform-express@11.1.13': + resolution: {integrity: sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-socket.io@11.1.12': - resolution: {integrity: sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==} + '@nestjs/platform-socket.io@11.1.13': + resolution: {integrity: sha512-04Rh16IopZzHRXt0ZjFASqt9oNFV/0m0NsYe4kVOSaTEoef3cH7cTFpNpHsfNHcc4QpYL963XE8SvIRcZs5L8A==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 rxjs: ^7.1.0 - '@nestjs/schedule@6.1.0': - resolution: {integrity: sha512-W25Ydc933Gzb1/oo7+bWzzDiOissE+h/dhIAPugA39b9MuIzBbLybuXpc1AjoQLczO3v0ldmxaffVl87W0uqoQ==} + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/core': ^10.0.0 || ^11.0.0 @@ -3764,8 +3507,8 @@ packages: peerDependencies: typescript: '>=4.8.2' - '@nestjs/swagger@11.2.5': - resolution: {integrity: sha512-wCykbEybMqiYcvkyzPW4SbXKcwra9AGdajm0MvFgKR3W+gd1hfeKlo67g/s9QCRc/mqUU4KOE5Qtk7asMeFuiA==} + '@nestjs/swagger@11.2.6': + resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==} peerDependencies: '@fastify/static': ^8.0.0 || ^9.0.0 '@nestjs/common': ^11.0.1 @@ -3781,8 +3524,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.12': - resolution: {integrity: sha512-W0M/i5nb9qRQpTQfJm+1mGT/+y4YezwwdcD7mxFG8JEZ5fz/ZEAk1Ayri2VBJKJUdo20B1ggnvqew4dlTMrSNg==} + '@nestjs/testing@11.1.13': + resolution: {integrity: sha512-bOWP8nLEZAOEEX8jAZGBCc1yU0+nv4g2ipc+QEzkVUe3eEEUKHKaeGafJ3GtDuGavlZKfkXEqflZuICdavu5dQ==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3794,8 +3537,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.1.12': - resolution: {integrity: sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==} + '@nestjs/websockets@11.1.13': + resolution: {integrity: sha512-8r8EadqBkrTYtH2uog42HfIb5fcP5a3iXymH/ityd9bO/gDson5Q1qbtCQRjuU++6NY12YYteKRu4eP/iErbLw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3835,97 +3578,97 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true - '@oazapfts/runtime@1.1.0': - resolution: {integrity: sha512-PwCn69pexqg/uhc0bpEHSlRFdfTtSnq3icXHd0wf4BQwZSMKsCerTnydzegVScEegYkokzIxMcl9li7on86A2w==} + '@oazapfts/runtime@1.2.0': + resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==} - '@opentelemetry/api-logs@0.210.0': - resolution: {integrity: sha512-CMtLxp+lYDriveZejpBND/2TmadrrhUfChyxzmkFtHaMDdSKfP59MAYyA0ICBvEBdm3iXwLcaj/8Ic/pnGw9Yg==} + '@opentelemetry/api-logs@0.211.0': + resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} engines: {node: '>=8.0.0'} '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/configuration@0.210.0': - resolution: {integrity: sha512-tM0ROS/hZM72kB55cSjDcghVcUXBJdGkGzpkhD7M1B/gpcvZPSGfjFgKN3dgmxNgF76NxtbUwv3ik0wS+Kz52g==} + '@opentelemetry/configuration@0.211.0': + resolution: {integrity: sha512-PNsCkzsYQKyv8wiUIsH+loC4RYyblOaDnVASBtKS22hK55ToWs2UP6IsrcfSWWn54wWTvVe2gnfwz67Pvrxf2Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks@2.4.0': - resolution: {integrity: sha512-jn0phJ+hU7ZuvaoZE/8/Euw3gvHJrn2yi+kXrymwObEPVPjtwCmkvXDRQCWli+fCTTF/aSOtXaLr7CLIvv3LQg==} + '@opentelemetry/context-async-hooks@2.5.0': + resolution: {integrity: sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.4.0': - resolution: {integrity: sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw==} + '@opentelemetry/core@2.5.0': + resolution: {integrity: sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.210.0': - resolution: {integrity: sha512-+BolenqOO6ow65go7uWRYPvvs/BBIWp1mtRn93VvGduqvMVH/IY8nXrt80a4L9hZ7lHi2Tq2/NcC3H2QzcWKag==} + '@opentelemetry/exporter-logs-otlp-grpc@0.211.0': + resolution: {integrity: sha512-UhOoWENNqyaAMP/dL1YXLkXt6ZBtovkDDs1p4rxto9YwJX1+wMjwg+Obfyg2kwpcMoaiIFT3KQIcLNW8nNGNfQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-http@0.210.0': - resolution: {integrity: sha512-Q8/SEQtgrErbVVRg9M9iaG8m5wdPNdU0UOF7U43sAhwfmPG92ZOk/aenKhg0DXSNJHhkCDNCgS1kSoErAB3z0A==} + '@opentelemetry/exporter-logs-otlp-http@0.211.0': + resolution: {integrity: sha512-c118Awf1kZirHkqxdcF+rF5qqWwNjJh+BB1CmQvN9AQHC/DUIldy6dIkJn3EKlQnQ3HmuNRKc/nHHt5IusN7mA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-proto@0.210.0': - resolution: {integrity: sha512-Y/yPc+gDhsWB7AsNzQWxblw4ULbvhCycMaQ2aAn+HSAVbgbMiZa0SbclPVHSnpnNzKSLVavFjweAr0pQA1KKLg==} + '@opentelemetry/exporter-logs-otlp-proto@0.211.0': + resolution: {integrity: sha512-kMvfKMtY5vJDXeLnwhrZMEwhZ2PN8sROXmzacFU/Fnl4Z79CMrOaL7OE+5X3SObRYlDUa7zVqaXp9ZetYCxfDQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-grpc@0.210.0': - resolution: {integrity: sha512-pWZ/Tjrqev9rdkqe8F6A9FGddLZrjl6iRAU5LBvvRL6I3PSgG8z1xM0cESAy1jzAF4wGohnAh8rB7hHzpUOYEA==} + '@opentelemetry/exporter-metrics-otlp-grpc@0.211.0': + resolution: {integrity: sha512-D/U3G8L4PzZp8ot5hX9wpgbTymgtLZCiwR7heMe4LsbGV4OdctS1nfyvaQHLT6CiGZ6FjKc1Vk9s6kbo9SWLXQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-http@0.210.0': - resolution: {integrity: sha512-JpLThG8Hh8A/Jzdzw9i4Ftu+EzvLaX/LouN+mOOHmadL0iror0Qsi3QWzucXeiUsDDsiYgjfKyi09e6sltytgA==} + '@opentelemetry/exporter-metrics-otlp-http@0.211.0': + resolution: {integrity: sha512-lfHXElPAoDSPpPO59DJdN5FLUnwi1wxluLTWQDayqrSPfWRnluzxRhD+g7rF8wbj1qCz0sdqABl//ug1IZyWvA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-proto@0.210.0': - resolution: {integrity: sha512-CFa7SOinYOVWIWJuQL7XFeyedzmFGIpHpSMNFE8Xefb6iGB4m+MukQecdssvPcJKYlfF5FpovEOLXwafAzsXWQ==} + '@opentelemetry/exporter-metrics-otlp-proto@0.211.0': + resolution: {integrity: sha512-61iNbffEpyZv/abHaz3BQM3zUtA2kVIDBM+0dS9RK68ML0QFLRGYa50xVMn2PYMToyfszEPEgFC3ypGae2z8FA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.210.0': - resolution: {integrity: sha512-8i+7d70Hho6pcheTtbqIuS+bo+AIX/oNUTMwIEZoehUE4ZdbGmeVaE+hJS2LAErFeFaU71w164lAgYyMUEQ8zw==} + '@opentelemetry/exporter-prometheus@0.211.0': + resolution: {integrity: sha512-cD0WleEL3TPqJbvxwz5MVdVJ82H8jl8mvMad4bNU24cB5SH2mRW5aMLDTuV4614ll46R//R3RMmci26mc2L99g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.210.0': - resolution: {integrity: sha512-1GPLOyxIfUX24WM8Oea+vx9d9TlewposUnsQXTjusxVMQ/dWvt5JIDJyTsfNDS412XRUOORgF97PwsfDY5QKGA==} + '@opentelemetry/exporter-trace-otlp-grpc@0.211.0': + resolution: {integrity: sha512-eFwx4Gvu6LaEiE1rOd4ypgAiWEdZu7Qzm2QNN2nJqPW1XDeAVH1eNwVcVQl+QK9HR/JCDZ78PZgD7xD/DBDqbw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-http@0.210.0': - resolution: {integrity: sha512-9JkyaCl70anEtuKZdoCQmjDuz1/paEixY/DWfsvHt7PGKq3t8/nQ/6/xwxHjG+SkPAUbo1Iq4h7STe7Pk2bc5A==} + '@opentelemetry/exporter-trace-otlp-http@0.211.0': + resolution: {integrity: sha512-F1Rv3JeMkgS//xdVjbQMrI3+26e5SXC7vXA6trx8SWEA0OUhw4JHB+qeHtH0fJn46eFItrYbL5m8j4qi9Sfaxw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.210.0': - resolution: {integrity: sha512-qVUY7Hsm/t5buGOtPcTV1Ch4W9kj2wGaQaAF5FO4XR8TMKl2GM45tUCnr0/1dF3wo4RG9khMxrddeQWdRL4fIg==} + '@opentelemetry/exporter-trace-otlp-proto@0.211.0': + resolution: {integrity: sha512-DkjXwbPiqpcPlycUojzG2RmR0/SIK8Gi9qWO9znNvSqgzrnAIE9x2n6yPfpZ+kWHZGafvsvA1lVXucTyyQa5Kg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@2.4.0': - resolution: {integrity: sha512-qpiXY0TUEFjBBp9b1na9LfuVQw6W8LH+te7uv+CC+0Up78ZDtZZwOjK2M7CL7Nspnw+yS4JdgEA7oxsBu0Ctsg==} + '@opentelemetry/exporter-zipkin@2.5.0': + resolution: {integrity: sha512-bk9VJgFgUAzkZzU8ZyXBSWiUGLOM3mZEgKJ1+jsZclhRnAoDNf+YBdq+G9R3cP0+TKjjWad+vVrY/bE/vRR9lA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 @@ -3936,62 +3679,62 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-http@0.210.0': - resolution: {integrity: sha512-dICO+0D0VBnrDOmDXOvpmaP0gvai6hNhJ5y6+HFutV0UoXc7pMgJlJY3O7AzT725cW/jP38ylmfHhQa7M0Nhww==} + '@opentelemetry/instrumentation-http@0.211.0': + resolution: {integrity: sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-ioredis@0.58.0': - resolution: {integrity: sha512-2tEJFeoM465A0FwPB0+gNvdM/xPBRIqNtC4mW+mBKy+ZKF9CWa7rEqv87OODGrigkEDpkH8Bs1FKZYbuHKCQNQ==} + '@opentelemetry/instrumentation-ioredis@0.59.0': + resolution: {integrity: sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-nestjs-core@0.56.0': - resolution: {integrity: sha512-2wKd6+/nKyZVTkElTHRZAAEQ7moGqGmTIXlZvfAeV/dNA+6zbbl85JBcyeUFIYt+I42Naq5RgKtUY8fK6/GE1g==} + '@opentelemetry/instrumentation-nestjs-core@0.57.0': + resolution: {integrity: sha512-mzTjjethjuk70o/vWUeV12QwMG9EAFJpkn13/q8zi++sNosf2hoGXTplIdbs81U8S3PJ4GxHKsBjM0bj1CGZ0g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pg@0.62.0': - resolution: {integrity: sha512-/ZSMRCyFRMjQVx7Wf+BIAOMEdN/XWBbAGTNLKfQgGYs1GlmdiIFkUy8Z8XGkToMpKrgZju0drlTQpqt4Ul7R6w==} + '@opentelemetry/instrumentation-pg@0.63.0': + resolution: {integrity: sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.210.0': - resolution: {integrity: sha512-sLMhyHmW9katVaLUOKpfCnxSGhZq2t1ReWgwsu2cSgxmDVMB690H9TanuexanpFI94PJaokrqbp8u9KYZDUT5g==} + '@opentelemetry/instrumentation@0.211.0': + resolution: {integrity: sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.210.0': - resolution: {integrity: sha512-uk78DcZoBNHIm26h0oXc8Pizh4KDJ/y04N5k/UaI9J7xR7mL8QcMcYPQG9xxN7m8qotXOMDRW6qTAyptav4+3w==} + '@opentelemetry/otlp-exporter-base@0.211.0': + resolution: {integrity: sha512-bp1+63V8WPV+bRI9EQG6E9YID1LIHYSZVbp7f+44g9tRzCq+rtw/o4fpL5PC31adcUsFiz/oN0MdLISSrZDdrg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.210.0': - resolution: {integrity: sha512-fEJs8UhkFMrdXMOCLXyKd2uc6N209tIi8IBNqSTi83ri+MlMFrBKnOtklmv9/zzxovoN5zD1waRt6XBFGPfmIw==} + '@opentelemetry/otlp-grpc-exporter-base@0.211.0': + resolution: {integrity: sha512-mR5X+N4SuphJeb7/K7y0JNMC8N1mB6gEtjyTLv+TSAhl0ZxNQzpSKP8S5Opk90fhAqVYD4R0SQSAirEBlH1KSA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.210.0': - resolution: {integrity: sha512-nkHBJVSJGOwkRZl+BFIr7gikA93/U8XkL2EWaiDbj3DVjmTEZQpegIKk0lT8oqQYfP8FC6zWNjuTfkaBVqa0ZQ==} + '@opentelemetry/otlp-transformer@0.211.0': + resolution: {integrity: sha512-julhCJ9dXwkOg9svuuYqqjXLhVaUgyUvO2hWbTxwjvLXX2rG3VtAaB0SzxMnGTuoCZizBT7Xqqm2V7+ggrfCXA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagator-b3@2.4.0': - resolution: {integrity: sha512-6VPsFiMUkJBre/86F0d+PZMaUCcuLA9DtZuC46KH8EeVEKZPEM2WlX35M/qmde8UpzoQL9qzdz54YjUYABt8Uw==} + '@opentelemetry/propagator-b3@2.5.0': + resolution: {integrity: sha512-g10m4KD73RjHrSvUge+sUxUl8m4VlgnGc6OKvo68a4uMfaLjdFU+AULfvMQE/APq38k92oGUxEzBsAZ8RN/YHg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.4.0': - resolution: {integrity: sha512-t6muBL/3AMD++1EMF658C/KIpj3gfmTmftX3mEQql4KIxNGFvacCmmTtrQt9IZAJmQRfjQRCkv+vsGbQugeJIw==} + '@opentelemetry/propagator-jaeger@2.5.0': + resolution: {integrity: sha512-t70ErZCncAR/zz5AcGkL0TF25mJiK1FfDPEQCgreyAHZ+mRJ/bNUiCnImIBDlP3mSDXy6N09DbUEKq0ktW98Hg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -4000,44 +3743,44 @@ packages: resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} - '@opentelemetry/resources@2.4.0': - resolution: {integrity: sha512-RWvGLj2lMDZd7M/5tjkI/2VHMpXebLgPKvBUd9LRasEWR2xAynDwEYZuLvY9P2NGG73HF07jbbgWX2C9oavcQg==} + '@opentelemetry/resources@2.5.0': + resolution: {integrity: sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.210.0': - resolution: {integrity: sha512-YuaL92Dpyk/Kc1o4e9XiaWWwiC0aBFN+4oy+6A9TP4UNJmRymPMEX10r6EMMFMD7V0hktiSig9cwWo59peeLCQ==} + '@opentelemetry/sdk-logs@0.211.0': + resolution: {integrity: sha512-O5nPwzgg2JHzo59kpQTPUOTzFi0Nv5LxryG27QoXBciX3zWM3z83g+SNOHhiQVYRWFSxoWn1JM2TGD5iNjOwdA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.4.0': - resolution: {integrity: sha512-qSbfq9mXbLMqmPEjijl32f3ZEmiHekebRggPdPjhHI6t1CsAQOR2Aw/SuTDftk3/l2aaPHpwP3xM2DkgBA1ANw==} + '@opentelemetry/sdk-metrics@2.5.0': + resolution: {integrity: sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.210.0': - resolution: {integrity: sha512-KymqUtYvfpblDNgGxBXYqCcDjYXwjOF7Muc6ocs0rMlG/66Hcs9KiJ7hg4zLOv63JubF/vxi5WXaLrQrPKyaZQ==} + '@opentelemetry/sdk-node@0.211.0': + resolution: {integrity: sha512-+s1eGjoqmPCMptNxcJJD4IxbWJKNLOQFNKhpwkzi2gLkEbCj6LzSHJNhPcLeBrBlBLtlSpibM+FuS7fjZ8SSFQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.4.0': - resolution: {integrity: sha512-WH0xXkz/OHORDLKqaxcUZS0X+t1s7gGlumr2ebiEgNZQl2b0upK2cdoD0tatf7l8iP74woGJ/Kmxe82jdvcWRw==} + '@opentelemetry/sdk-trace-base@2.5.0': + resolution: {integrity: sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.4.0': - resolution: {integrity: sha512-MBc2l04hZPYygnWPT38UiOPy9ueutPqmJ47z0m9IKuoVQh3MblmbSgwspjhdHagZLfSfmlzhWR1xtbgVNmjX2A==} + '@opentelemetry/sdk-trace-node@2.5.0': + resolution: {integrity: sha512-O6N/ejzburFm2C84aKNrwJVPpt6HSTSq8T0ZUMq3xT2XmqT4cwxUItcL5UWGThYuq8RTcbH8u1sfj6dmRci0Ow==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.38.0': - resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==} + '@opentelemetry/semantic-conventions@1.39.0': + resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} engines: {node: '>=14'} '@opentelemetry/sql-common@0.41.2': @@ -4078,36 +3821,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -4172,8 +3921,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.57.0': - resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} hasBin: true @@ -4397,66 +4146,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -4523,178 +4285,6 @@ packages: '@slorber/remark-comment@1.0.0': resolution: {integrity: sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==} - '@smithy/abort-controller@4.2.8': - resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} - engines: {node: '>=18.0.0'} - - '@smithy/config-resolver@4.4.6': - resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} - engines: {node: '>=18.0.0'} - - '@smithy/core@3.20.7': - resolution: {integrity: sha512-aO7jmh3CtrmPsIJxUwYIzI5WVlMK8BMCPQ4D4nTzqTqBhbzvxHNzBMGcEg13yg/z9R2Qsz49NUFl0F0lVbTVFw==} - engines: {node: '>=18.0.0'} - - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} - engines: {node: '>=18.0.0'} - - '@smithy/fetch-http-handler@5.3.9': - resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-node@4.2.8': - resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.2.8': - resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} - engines: {node: '>=18.0.0'} - - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-content-length@4.2.8': - resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-endpoint@4.4.8': - resolution: {integrity: sha512-TV44qwB/T0OMMzjIuI+JeS0ort3bvlPJ8XIH0MSlGADraXpZqmyND27ueuAL3E14optleADWqtd7dUgc2w+qhQ==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-retry@4.4.24': - resolution: {integrity: sha512-yiUY1UvnbUFfP5izoKLtfxDSTRv724YRRwyiC/5HYY6vdsVDcDOXKSXmkJl/Hovcxt5r+8tZEUAdrOaCJwrl9Q==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-serde@4.2.9': - resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-stack@4.2.8': - resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} - engines: {node: '>=18.0.0'} - - '@smithy/node-config-provider@4.3.8': - resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.4.8': - resolution: {integrity: sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==} - engines: {node: '>=18.0.0'} - - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} - engines: {node: '>=18.0.0'} - - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-builder@4.2.8': - resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-parser@4.2.8': - resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} - engines: {node: '>=18.0.0'} - - '@smithy/service-error-classification@4.2.8': - resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.4.3': - resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} - engines: {node: '>=18.0.0'} - - '@smithy/signature-v4@5.3.8': - resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.10.9': - resolution: {integrity: sha512-Je0EvGXVJ0Vrrr2lsubq43JGRIluJ/hX17aN/W/A0WfE+JpoMdI8kwk2t9F0zTX9232sJDGcoH4zZre6m6f/sg==} - engines: {node: '>=18.0.0'} - - '@smithy/types@4.12.0': - resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} - engines: {node: '>=18.0.0'} - - '@smithy/url-parser@4.2.8': - resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} - engines: {node: '>=18.0.0'} - - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-browser@4.3.23': - resolution: {integrity: sha512-mMg+r/qDfjfF/0psMbV4zd7F/i+rpyp7Hjh0Wry7eY15UnzTEId+xmQTGDU8IdZtDfbGQxuWNfgBZKBj+WuYbA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-node@4.2.26': - resolution: {integrity: sha512-EQqe/WkbCinah0h1lMWh9ICl0Ob4lyl20/10WTB35SC9vDQfD8zWsOT+x2FIOXKAoZQ8z/y0EFMoodbcqWJY/w==} - engines: {node: '>=18.0.0'} - - '@smithy/util-endpoints@3.2.8': - resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.2.8': - resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.2.8': - resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.10': - resolution: {integrity: sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} - engines: {node: '>=18.0.0'} - - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} - engines: {node: '>=18.0.0'} - '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -4710,8 +4300,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@sveltejs/acorn-typescript@1.0.8': - resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==} + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} peerDependencies: acorn: ^8.9.0 @@ -4720,15 +4310,15 @@ packages: peerDependencies: '@sveltejs/kit': ^2.0.0 - '@sveltejs/enhanced-img@0.9.2': - resolution: {integrity: sha512-hAYZ8YFgYtqrQ0dXyq6rdmHBFyG+eIQnNjdIoVhqeZQEBIREXoBThkx+7FtDa6ZV35lTRaT9dgFKF4W+4LbuaQ==} + '@sveltejs/enhanced-img@0.10.0': + resolution: {integrity: sha512-+nSrtNfs2dgKQ6RHMoKO6chl1QoO8JsuwKHkj9LkA2fUwzDYkeYoWvJzddOJIbgmowMdhi9cLo6tckSU+Kk7DQ==} peerDependencies: '@sveltejs/vite-plugin-svelte': ^6.0.0 svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.49.5': - resolution: {integrity: sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==} + '@sveltejs/kit@2.52.2': + resolution: {integrity: sha512-1in76dftrofUt138rVLvYuwiQLkg9K3cG8agXEE6ksf7gCGs8oIr3+pFrVtbRmY9JvW+psW5fvLM/IwVybOLBA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4836,68 +4426,72 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.15.8': - resolution: {integrity: sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==} + '@swc/core-darwin-arm64@1.15.11': + resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.8': - resolution: {integrity: sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==} + '@swc/core-darwin-x64@1.15.11': + resolution: {integrity: sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.8': - resolution: {integrity: sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==} + '@swc/core-linux-arm-gnueabihf@1.15.11': + resolution: {integrity: sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.8': - resolution: {integrity: sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==} + '@swc/core-linux-arm64-gnu@1.15.11': + resolution: {integrity: sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.8': - resolution: {integrity: sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==} + '@swc/core-linux-arm64-musl@1.15.11': + resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] - '@swc/core-linux-x64-gnu@1.15.8': - resolution: {integrity: sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==} + '@swc/core-linux-x64-gnu@1.15.11': + resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] - '@swc/core-linux-x64-musl@1.15.8': - resolution: {integrity: sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==} + '@swc/core-linux-x64-musl@1.15.11': + resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.8': - resolution: {integrity: sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==} + '@swc/core-win32-arm64-msvc@1.15.11': + resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.8': - resolution: {integrity: sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==} + '@swc/core-win32-ia32-msvc@1.15.11': + resolution: {integrity: sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.8': - resolution: {integrity: sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==} + '@swc/core-win32-x64-msvc@1.15.11': + resolution: {integrity: sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.8': - resolution: {integrity: sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==} + '@swc/core@1.15.11': + resolution: {integrity: sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4956,24 +4550,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -5048,10 +4646,6 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} - engines: {node: '>= 10'} - '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -5274,9 +4868,6 @@ packages: '@types/fluent-ffmpeg@2.1.28': resolution: {integrity: sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==} - '@types/geojson-vt@3.2.5': - resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==} - '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -5388,17 +4979,14 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@20.19.30': - resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} - '@types/node@24.10.13': resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - '@types/node@25.0.9': - resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} - '@types/nodemailer@7.0.5': - resolution: {integrity: sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==} + '@types/nodemailer@7.0.9': + resolution: {integrity: sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==} '@types/oidc-provider@9.5.0': resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} @@ -5442,8 +5030,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@19.2.8': - resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -5526,63 +5114,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.53.0': - resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==} + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.53.0 + '@typescript-eslint/parser': ^8.55.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.53.0': - resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==} + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.53.0': - resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.53.0': - resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.53.0': - resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==} + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.53.0': - resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==} + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.53.0': - resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.53.0': - resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==} + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.53.0': - resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==} + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.53.0': - resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -5681,18 +5269,14 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - '@zoom-image/core@0.41.4': - resolution: {integrity: sha512-zUJNHWQzx8rmfNOlp2Rr0+n8I7QK9hLNThnusdtvz20/HN+J//RcDJmCuRDj6jUW/qJGh9FWR5sROMFBuPLPfQ==} + '@zoom-image/core@0.42.0': + resolution: {integrity: sha512-aF7siQqxqmOVlBd65deaCM7L/6V80Rp7HazZJpxtErh8zAn5itXXKBv1KA1NufSPfRZsXl1QtysxkjB3gVIzxw==} - '@zoom-image/svelte@0.3.8': - resolution: {integrity: sha512-rkXS+JS4qkBccmRK9+I5j+Pe4rp78GWK/7y0EduBJNtt38q+AwmKhhQs8oTMKTU6lOzLgxjXy1TI802mtvcAmw==} + '@zoom-image/svelte@0.3.9': + resolution: {integrity: sha512-27Nze2f0W7Jop12imiWYvZGqiAlmQbBCqMVJPtUvmaBdv2KY4BhrSe4k7pBJaQId5dMF9SwUPo7obrtm9dCzuQ==} peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 - abab@2.0.6: - resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} - deprecated: Use your platform's native atob() and btoa() methods instead - abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -5712,9 +5296,6 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-globals@7.0.1: - resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -5735,8 +5316,8 @@ packages: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -5794,6 +5375,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + algoliasearch-helper@3.26.1: resolution: {integrity: sha512-CAlCxm4fYBXtvc5MamDzP6Svu8rW4z9me4DCBY1rQ2UDJ0u0flWmusQ8M3nOExZsLLRcUwUPoRAPMrhzOG3erw==} peerDependencies: @@ -5933,8 +5517,8 @@ packages: autocomplete.js@0.37.1: resolution: {integrity: sha512-PgSe9fHYhZEsm/9jggbjtVsGXJkPLvd+9mC7gZJ662vVL5CRWEtm/mIrrzCx0MrNxHVwxD5d00UOn6NsmL2LUQ==} - autoprefixer@10.4.23: - resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} + autoprefixer@10.4.24: + resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -6031,8 +5615,8 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.9.7: - resolution: {integrity: sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true batch-cluster@16.0.0: @@ -6059,8 +5643,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bits-ui@2.14.4: - resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==} + bits-ui@2.16.0: + resolution: {integrity: sha512-utsUZE7W7MxOQF1jmSYfzUrt2nZxgkq0yPqQcBQ0WQDMq8ETd1yEiHlPpqhMrpKU7IivjSf4XVysDDy+UVkMUw==} engines: {node: '>=20'} peerDependencies: '@internationalized/date': ^3.8.1 @@ -6083,9 +5667,6 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - bowser@2.13.1: - resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} - boxen@6.2.1: resolution: {integrity: sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6133,8 +5714,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.66.5: - resolution: {integrity: sha512-DC1E7P03L+TfNHv+2SGxwNYvtb0oJPODWSKkWdfis0heU5zFW16vjM7fCjwlxMdGWw2w28EI3mTRfYLEHeQQSw==} + bullmq@5.68.0: + resolution: {integrity: sha512-PywC7eTcPrKVQN5iEfhs5ats90nSLr8dzsyIhgviO8qQRTHnTq/SnETq2E8Do1RLg7Qw1Q0p5htBPI/cUGAlHg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -6219,8 +5800,8 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001760: - resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} canvas@2.11.2: resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} @@ -6595,8 +6176,8 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} cose-base@1.0.3: @@ -6634,8 +6215,8 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} - cron@4.3.5: - resolution: {integrity: sha512-hKPP7fq1+OfyCqoePkKfVq7tNAdFwiQORr4lZUHwrf0tebC65fYEeWgOrXOL6prn1/fegGOdTfrM6e34PJfksg==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} engines: {node: '>=18.x'} cross-spawn@7.0.6: @@ -6731,9 +6312,6 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - csscolorparser@1.0.3: - resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} - cssdb@8.5.2: resolution: {integrity: sha512-Pmoj9RmD8RIoIzA2EQWO4D4RMeDts0tgAH0VXdlNdxjuBGI3a9wMOIcUwaPNmD4r2qtIa06gqkIf7sECl+cBCg==} @@ -6770,16 +6348,6 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - cssom@0.3.8: - resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} - - cssom@0.5.0: - resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} - - cssstyle@2.3.0: - resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} - engines: {node: '>=8'} - cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -6947,10 +6515,6 @@ packages: dagre-d3-es@7.0.13: resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} - data-urls@3.0.2: - resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} - engines: {node: '>=12'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -7108,8 +6672,8 @@ packages: engines: {node: '>= 4.0.0'} hasBin: true - devalue@5.6.2: - resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -7189,11 +6753,6 @@ packages: domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - domexception@4.0.0: - resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} - engines: {node: '>=12'} - deprecated: Use your platform's native DOMException instead - domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -7218,8 +6777,8 @@ packages: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -7229,9 +6788,6 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - earcut@2.2.4: - resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} - earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -7244,8 +6800,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -7287,8 +6843,8 @@ packages: resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} entities@2.2.0: @@ -7364,8 +6920,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true @@ -7392,19 +6948,14 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - eslint-plugin-compat@6.0.2: - resolution: {integrity: sha512-1ME+YfJjmOz1blH0nPZpHgjMGK4kjgEeoYqGCqoBPQ/mGu/dJzdoP0f1C8H2jcWZjzhZjAMccbM/VdXhPORIfA==} + eslint-plugin-compat@6.1.0: + resolution: {integrity: sha512-xiwHz7mj6+Zj7NWOO/uaWdrQ6zP0zL5CPyKVCNlB4JaoUFeYPYwejf5toqyHGlXzhuPUdCpg31uBRiWqcgiS0A==} engines: {node: '>=18.x'} peerDependencies: eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 @@ -7423,11 +6974,11 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-svelte@3.14.0: - resolution: {integrity: sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==} + eslint-plugin-svelte@3.15.0: + resolution: {integrity: sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.1 || ^9.0.0 + eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: svelte: @@ -7485,8 +7036,8 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} - esrap@2.2.1: - resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==} + esrap@2.2.3: + resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -7613,9 +7164,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fabric@6.9.1: - resolution: {integrity: sha512-TqG08Xbt4rtlPsXgCjSUcZz/RsyEP57Qo21nCVRkw7zz9nR0co4SLkL9Q/zQh3tC1Yxap6M5jKFHUKV6SgPovg==} - engines: {node: '>=16.20.0'} + fabric@7.2.0: + resolution: {integrity: sha512-XSYmSqSMrlbCg+/j7/uU/PFeZuA5hHRDp7sGbDlMvz/T6BHt2MQSOYtz/AIdr+kmReA1s5jTzHJ8AjHwYUcmfQ==} + engines: {node: '>=20.0.0'} factory.ts@1.4.2: resolution: {integrity: sha512-8x2hqK1+EGkja4Ah8H3nkP7rDUJsBK1N3iFDqzqsaOV114o2IphSdVkFIw9nDHHr37gFFy2NXeN6n10ieqHzZg==} @@ -7649,10 +7200,6 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-parser@5.2.5: - resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} - hasBin: true - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -7863,12 +7410,6 @@ packages: resolution: {integrity: sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==} hasBin: true - geojson-vt@3.2.1: - resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} - - geojson-vt@4.0.2: - resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} - geojson@0.5.0: resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==} engines: {node: '>= 0.10'} @@ -7941,6 +7482,10 @@ packages: resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} engines: {node: 20 || >=22} + glob@13.0.2: + resolution: {integrity: sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==} + engines: {node: 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -7993,9 +7538,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - grid-index@1.1.0: - resolution: {integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==} - gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -8011,8 +7553,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.3.0: - resolution: {integrity: sha512-5qJbkqcvR8j/a4av5IWqqIWmEGf9dt6OhGMS6qxCgjSOBGzGa5XLoqg40OyD8XNzQ+g1g2zsXi10kjfpzYH55Q==} + happy-dom@20.6.1: + resolution: {integrity: sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -8113,10 +7655,6 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} - html-encoding-sniffer@3.0.0: - resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} - engines: {node: '>=12'} - html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -8188,10 +7726,6 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} - http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} - engines: {node: '>= 6'} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -8341,14 +7875,14 @@ packages: intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} - intl-messageformat@11.0.9: - resolution: {integrity: sha512-xA4aCCMnCxynKV5kI7V0GlMf+BGJxsXQRwr5tfEgmcB791eDEQa4r+s4wU7GqMR0jx7+K4jyEH2UfBpVGTDNPQ==} + intl-messageformat@11.1.2: + resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==} invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.9.1: - resolution: {integrity: sha512-BXNqFQ66oOsR82g9ajFFsR8ZKrjVvYCLyeML9IvSMAsP56XH2VXBdZjmI11p65nXXJxTEt1hie3J2QeFJVgrtQ==} + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} ip-address@10.1.0: @@ -8544,9 +8078,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} @@ -8630,15 +8164,6 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsdom@20.0.3: - resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} - engines: {node: '>=14'} - peerDependencies: - canvas: 2.11.2 - peerDependenciesMeta: - canvas: - optional: true - jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -8721,9 +8246,6 @@ packages: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true - kdbush@3.0.0: - resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} - kdbush@4.0.2: resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} @@ -8843,24 +8365,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -8968,9 +8494,6 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -9009,8 +8532,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -9054,12 +8577,8 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} - mapbox-gl@1.13.3: - resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} - engines: {node: '>=6.4.0'} - - maplibre-gl@5.16.0: - resolution: {integrity: sha512-/VDY89nr4jgLJyzmhy325cG6VUI02WkZ/UfVuDbG/piXzo6ODnM+omDFIwWY8tsEsBG26DNDmNMn3Y2ikHsBiA==} + maplibre-gl@5.18.0: + resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -9085,8 +8604,8 @@ packages: engines: {node: '>= 20'} hasBin: true - marked@17.0.1: - resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + marked@17.0.3: + resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} engines: {node: '>= 20'} hasBin: true @@ -9391,8 +8910,8 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + minimatch@10.1.2: + resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} minimatch@3.1.2: @@ -9413,8 +8932,8 @@ packages: resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} engines: {node: '>=16 || 14 >=14.17'} - minipass-fetch@5.0.0: - resolution: {integrity: sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==} + minipass-fetch@5.0.1: + resolution: {integrity: sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==} engines: {node: ^20.17.0 || >=22.9.0} minipass-flush@1.0.5: @@ -9425,8 +8944,8 @@ packages: resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} engines: {node: '>=8'} - minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + minipass-sized@2.0.0: + resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} engines: {node: '>=8'} minipass@3.3.6: @@ -9520,10 +9039,6 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - mute-stream@3.0.0: - resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} - engines: {node: ^20.17.0 || >=22.9.0} - mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -9643,16 +9158,16 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-gyp@12.1.0: - resolution: {integrity: sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g==} + node-gyp@12.2.0: + resolution: {integrity: sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nodemailer@7.0.12: - resolution: {integrity: sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==} + nodemailer@7.0.13: + resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==} engines: {node: '>=6.0.0'} nopt@1.0.10: @@ -9711,8 +9226,8 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true - oauth4webapi@3.8.3: - resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -9777,8 +9292,8 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true - openid-client@6.8.1: - resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + openid-client@6.8.2: + resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -9970,8 +9485,8 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.10.0: - resolution: {integrity: sha512-ur/eoPKzDx2IjPaYyXS6Y8NSblxM7X64deV2ObV57vhjsWiwLvUD6meukAzogiOsu60GO8m/3Cb6FdJsWNjwXg==} + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -9989,8 +9504,8 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.17.1: - resolution: {integrity: sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==} + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -10034,13 +9549,13 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true @@ -10051,8 +9566,8 @@ packages: pmtiles@3.2.1: resolution: {integrity: sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==} - pmtiles@4.3.2: - resolution: {integrity: sha512-Ath2F2U2E37QyNXjN1HOF+oLiNIbdrDYrk/K3C9K4Pgw2anwQX10y4WYWEH9O75vPiu0gBbSWIAbSG19svyvZg==} + pmtiles@4.4.0: + resolution: {integrity: sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==} pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} @@ -10542,9 +10057,6 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - potpack@1.0.2: - resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} - potpack@2.1.0: resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} @@ -10578,8 +10090,8 @@ packages: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - prettier@3.8.0: - resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -10656,9 +10168,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -10682,9 +10191,6 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -10696,9 +10202,6 @@ packages: resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} engines: {node: '>=18'} - quickselect@2.0.0: - resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} - quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -10743,10 +10246,10 @@ packages: peerDependencies: react: ^18.3.1 - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^19.2.3 + react: ^19.2.4 react-email@4.3.2: resolution: {integrity: sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA==} @@ -10798,8 +10301,8 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -11153,6 +10656,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -11182,8 +10690,8 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -11258,8 +10766,8 @@ packages: resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==} engines: {node: '>=0.12.18'} - simple-icons@16.4.0: - resolution: {integrity: sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==} + simple-icons@16.9.0: + resolution: {integrity: sha512-aKst2C7cLkFyaiQ/Crlwxt9xYOpGPk05XuJZ0ZTJNNCzHCKYrGWz2ebJSi5dG8CmTCxUF/BGs6A8uyJn/EQxqw==} engines: {node: '>=0.12.18'} sirv@2.0.4: @@ -11387,8 +10895,8 @@ packages: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} - ssri@13.0.0: - resolution: {integrity: sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==} + ssri@13.0.1: + resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} engines: {node: ^20.17.0 || >=22.9.0} stackback@0.0.2: @@ -11486,9 +10994,6 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - strnum@2.1.2: - resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} - strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} @@ -11520,9 +11025,6 @@ packages: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} - supercluster@7.1.5: - resolution: {integrity: sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==} - supercluster@8.0.1: resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} @@ -11547,8 +11049,8 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.3.5: - resolution: {integrity: sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==} + svelte-check@4.3.6: + resolution: {integrity: sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: @@ -11585,8 +11087,8 @@ packages: peerDependencies: svelte: ^5.0.0 - svelte-maplibre@1.2.5: - resolution: {integrity: sha512-Uklcbi6inW9GA0MuSusbXmFr/MQPmXrjuP8hS1+yFX3ySvCQ477tsM3I7Jo/fUDK3XAxFSIHW6hZfucnM3kXwQ==} + svelte-maplibre@1.2.6: + resolution: {integrity: sha512-NntxiZptS07HwblUxIkDllAeBSj6DTyEtECkOqxEi3e/uam7Qunkd/Cp535NN1K7eIx5MLs4cyAa8jgPDgGLFw==} peerDependencies: '@deck.gl/core': ^9 '@deck.gl/layers': ^9 @@ -11620,8 +11122,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.48.0: - resolution: {integrity: sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==} + svelte@5.51.5: + resolution: {integrity: sha512-/4tR5cLsWOgH3wnNRXnFoWaJlwPGbJanZPSKSD6nHM2y01dvXeEF4Nx7jevoZ+UpJpkIHh6mY2tqDncuI4GHng==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11720,8 +11222,8 @@ packages: engines: {node: '>=10'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - tar@7.5.2: - resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} + tar@7.5.7: + resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -11823,9 +11325,6 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyqueue@2.0.3: - resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} - tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -11867,10 +11366,6 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -11878,10 +11373,6 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@3.0.0: - resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} - engines: {node: '>=12'} - tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -11986,8 +11477,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.53.0: - resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==} + typescript-eslint@8.55.0: + resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -12001,8 +11492,8 @@ packages: ua-is-frozen@0.1.2: resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} - ua-parser-js@2.0.8: - resolution: {integrity: sha512-BdnBM5waFormdrOFBU+cA90R689V0tWUWlIG2i30UXxElHjuCu5+dOV2Etw3547jcQ/yaLtPm9wrqIuOY2bSJg==} + ua-parser-js@2.0.9: + resolution: {integrity: sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==} hasBin: true ufo@1.6.2: @@ -12028,9 +11519,6 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -12109,10 +11597,6 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -12130,8 +11614,8 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} - update-browserslist-db@1.2.2: - resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -12156,9 +11640,6 @@ packages: file-loader: optional: true - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -12245,13 +11726,10 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-tsconfig-paths@6.0.4: - resolution: {integrity: sha512-iIsEJ+ek5KqRTK17pmxtgIxXtqr3qDdE6OxrP9mVeGhVDNXRJTKN/l9oMbujTQNzMLe6XZ8qmpztfbkPu2TiFQ==} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' - peerDependenciesMeta: - vite: - optional: true vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} @@ -12355,16 +11833,9 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - vt-pbf@3.1.3: - resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} - w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - w3c-xmlserializer@4.0.0: - resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} - engines: {node: '>=14'} - w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -12462,11 +11933,6 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} - whatwg-encoding@2.0.0: - resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} - engines: {node: '>=12'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -12480,10 +11946,6 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@11.0.0: - resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} - engines: {node: '>=12'} - whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -12503,8 +11965,8 @@ packages: engines: {node: '>= 8'} hasBin: true - which@6.0.0: - resolution: {integrity: sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==} + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true @@ -12542,10 +12004,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -12600,10 +12058,6 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true - xml-name-validator@4.0.0: - resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} - engines: {node: '>=12'} - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -12908,405 +12362,7 @@ snapshots: lru-cache: 10.4.3 optional: true - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.969.0 - '@aws-sdk/util-locate-window': 3.965.2 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.969.0 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/client-sesv2@3.971.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.970.0 - '@aws-sdk/credential-provider-node': 3.971.0 - '@aws-sdk/middleware-host-header': 3.969.0 - '@aws-sdk/middleware-logger': 3.969.0 - '@aws-sdk/middleware-recursion-detection': 3.969.0 - '@aws-sdk/middleware-user-agent': 3.970.0 - '@aws-sdk/region-config-resolver': 3.969.0 - '@aws-sdk/signature-v4-multi-region': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@aws-sdk/util-endpoints': 3.970.0 - '@aws-sdk/util-user-agent-browser': 3.969.0 - '@aws-sdk/util-user-agent-node': 3.971.0 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.20.7 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.8 - '@smithy/middleware-retry': 4.4.24 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.23 - '@smithy/util-defaults-mode-node': 4.2.26 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sso@3.971.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.970.0 - '@aws-sdk/middleware-host-header': 3.969.0 - '@aws-sdk/middleware-logger': 3.969.0 - '@aws-sdk/middleware-recursion-detection': 3.969.0 - '@aws-sdk/middleware-user-agent': 3.970.0 - '@aws-sdk/region-config-resolver': 3.969.0 - '@aws-sdk/types': 3.969.0 - '@aws-sdk/util-endpoints': 3.970.0 - '@aws-sdk/util-user-agent-browser': 3.969.0 - '@aws-sdk/util-user-agent-node': 3.971.0 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.20.7 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.8 - '@smithy/middleware-retry': 4.4.24 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.23 - '@smithy/util-defaults-mode-node': 4.2.26 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.970.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@aws-sdk/xml-builder': 3.969.0 - '@smithy/core': 3.20.7 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.970.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.970.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.10 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.971.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/credential-provider-env': 3.970.0 - '@aws-sdk/credential-provider-http': 3.970.0 - '@aws-sdk/credential-provider-login': 3.971.0 - '@aws-sdk/credential-provider-process': 3.970.0 - '@aws-sdk/credential-provider-sso': 3.971.0 - '@aws-sdk/credential-provider-web-identity': 3.971.0 - '@aws-sdk/nested-clients': 3.971.0 - '@aws-sdk/types': 3.969.0 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.971.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/nested-clients': 3.971.0 - '@aws-sdk/types': 3.969.0 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.971.0': - dependencies: - '@aws-sdk/credential-provider-env': 3.970.0 - '@aws-sdk/credential-provider-http': 3.970.0 - '@aws-sdk/credential-provider-ini': 3.971.0 - '@aws-sdk/credential-provider-process': 3.970.0 - '@aws-sdk/credential-provider-sso': 3.971.0 - '@aws-sdk/credential-provider-web-identity': 3.971.0 - '@aws-sdk/types': 3.969.0 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-process@3.970.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.971.0': - dependencies: - '@aws-sdk/client-sso': 3.971.0 - '@aws-sdk/core': 3.970.0 - '@aws-sdk/token-providers': 3.971.0 - '@aws-sdk/types': 3.969.0 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.971.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/nested-clients': 3.971.0 - '@aws-sdk/types': 3.969.0 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/middleware-host-header@3.969.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.969.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-recursion-detection@3.969.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-s3@3.970.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@aws-sdk/util-arn-parser': 3.968.0 - '@smithy/core': 3.20.7 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.10 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.970.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@aws-sdk/util-endpoints': 3.970.0 - '@smithy/core': 3.20.7 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/nested-clients@3.971.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.970.0 - '@aws-sdk/middleware-host-header': 3.969.0 - '@aws-sdk/middleware-logger': 3.969.0 - '@aws-sdk/middleware-recursion-detection': 3.969.0 - '@aws-sdk/middleware-user-agent': 3.970.0 - '@aws-sdk/region-config-resolver': 3.969.0 - '@aws-sdk/types': 3.969.0 - '@aws-sdk/util-endpoints': 3.970.0 - '@aws-sdk/util-user-agent-browser': 3.969.0 - '@aws-sdk/util-user-agent-node': 3.971.0 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.20.7 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.8 - '@smithy/middleware-retry': 4.4.24 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.23 - '@smithy/util-defaults-mode-node': 4.2.26 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/region-config-resolver@3.969.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@smithy/config-resolver': 4.4.6 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/signature-v4-multi-region@3.970.0': - dependencies: - '@aws-sdk/middleware-sdk-s3': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.971.0': - dependencies: - '@aws-sdk/core': 3.970.0 - '@aws-sdk/nested-clients': 3.971.0 - '@aws-sdk/types': 3.969.0 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/types@3.969.0': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-arn-parser@3.968.0': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.970.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 - tslib: 2.8.1 - - '@aws-sdk/util-locate-window@3.965.2': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-browser@3.969.0': - dependencies: - '@aws-sdk/types': 3.969.0 - '@smithy/types': 4.12.0 - bowser: 2.13.1 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.971.0': - dependencies: - '@aws-sdk/middleware-user-agent': 3.970.0 - '@aws-sdk/types': 3.969.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.969.0': - dependencies: - '@smithy/types': 4.12.0 - fast-xml-parser: 5.2.5 - tslib: 2.8.1 - - '@aws/lambda-invoke-store@0.2.3': {} - - '@babel/code-frame@7.28.6': + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 @@ -13316,7 +12372,7 @@ snapshots: '@babel/core@7.28.5': dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@babel/generator': 7.28.5 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) @@ -14038,13 +13094,13 @@ snapshots: '@babel/template@7.27.2': dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 '@babel/traverse@7.28.5': dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.5 @@ -14438,26 +13494,26 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@docsearch/core@4.3.1(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docsearch/core@4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': optionalDependencies: - '@types/react': 19.2.8 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) '@docsearch/css@4.3.2': {} - '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': dependencies: '@ai-sdk/react': 2.0.115(react@18.3.1)(zod@4.2.1) '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.46.0)(algoliasearch@5.46.0)(search-insights@2.17.3) - '@docsearch/core': 4.3.1(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docsearch/core': 4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docsearch/css': 4.3.2 ai: 5.0.113(zod@4.2.1) algoliasearch: 5.46.0 marked: 16.4.2 zod: 4.2.1 optionalDependencies: - '@types/react': 19.2.8 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) search-insights: 2.17.3 @@ -14531,7 +13587,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: '@docusaurus/babel': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/bundler': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) @@ -14540,7 +13596,7 @@ snapshots: '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) boxen: 6.2.1 chalk: 4.1.2 chokidar: 3.6.0 @@ -14569,7 +13625,7 @@ snapshots: react-router: 5.3.4(react@18.3.1) react-router-config: 5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1) react-router-dom: 5.3.4(react@18.3.1) - semver: 7.7.3 + semver: 7.7.4 serve-handler: 6.1.6 tinypool: 1.1.1 tslib: 2.8.1 @@ -14646,7 +13702,7 @@ snapshots: dependencies: '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.8 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 18.3.1 @@ -14660,13 +13716,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14701,13 +13757,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14741,9 +13797,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14771,9 +13827,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14798,9 +13854,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fs-extra: 11.3.2 @@ -14826,9 +13882,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14852,9 +13908,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/gtag.js': 0.0.12 @@ -14879,9 +13935,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14905,9 +13961,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14936,9 +13992,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14966,22 +14022,22 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -15008,25 +14064,25 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@18.3.1)': dependencies: - '@types/react': 19.2.8 + '@types/react': 19.2.14 react: 18.3.1 - '@docusaurus/theme-classic@3.9.2(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-classic@3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) clsx: 2.1.1 infima: 0.2.0-alpha.45 lodash: 4.17.23 @@ -15058,15 +14114,15 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.8 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -15082,11 +14138,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) mermaid: 11.12.2 @@ -15112,13 +14168,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': dependencies: - '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -15165,7 +14221,7 @@ snapshots: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 '@types/mdast': 4.0.4 - '@types/react': 19.2.8 + '@types/react': 19.2.14 commander: 5.1.0 joi: 17.13.3 react: 18.3.1 @@ -15256,7 +14312,7 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.27.3': optional: true '@esbuild/android-arm64@0.19.12': @@ -15265,7 +14321,7 @@ snapshots: '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm@0.19.12': @@ -15274,7 +14330,7 @@ snapshots: '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-x64@0.19.12': @@ -15283,7 +14339,7 @@ snapshots: '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/android-x64@0.27.3': optional: true '@esbuild/darwin-arm64@0.19.12': @@ -15292,7 +14348,7 @@ snapshots: '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-x64@0.19.12': @@ -15301,7 +14357,7 @@ snapshots: '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.19.12': @@ -15310,7 +14366,7 @@ snapshots: '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-x64@0.19.12': @@ -15319,7 +14375,7 @@ snapshots: '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/linux-arm64@0.19.12': @@ -15328,7 +14384,7 @@ snapshots: '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm@0.19.12': @@ -15337,7 +14393,7 @@ snapshots: '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-ia32@0.19.12': @@ -15346,7 +14402,7 @@ snapshots: '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-loong64@0.19.12': @@ -15355,7 +14411,7 @@ snapshots: '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-mips64el@0.19.12': @@ -15364,7 +14420,7 @@ snapshots: '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-ppc64@0.19.12': @@ -15373,7 +14429,7 @@ snapshots: '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-riscv64@0.19.12': @@ -15382,7 +14438,7 @@ snapshots: '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-s390x@0.19.12': @@ -15391,7 +14447,7 @@ snapshots: '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-x64@0.19.12': @@ -15400,13 +14456,13 @@ snapshots: '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-x64@0.19.12': @@ -15415,13 +14471,13 @@ snapshots: '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-x64@0.19.12': @@ -15430,13 +14486,13 @@ snapshots: '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/sunos-x64@0.19.12': @@ -15445,7 +14501,7 @@ snapshots: '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/sunos-x64@0.27.3': optional: true '@esbuild/win32-arm64@0.19.12': @@ -15454,7 +14510,7 @@ snapshots: '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.3': optional: true '@esbuild/win32-ia32@0.19.12': @@ -15463,7 +14519,7 @@ snapshots: '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-ia32@0.27.3': optional: true '@esbuild/win32-x64@0.19.12': @@ -15472,7 +14528,7 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/win32-x64@0.27.3': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': @@ -15527,12 +14583,12 @@ snapshots: dependencies: urlpattern-polyfill: 8.0.2 - '@faker-js/faker@10.2.0': {} + '@faker-js/faker@10.3.0': {} '@fig/complete-commander@3.2.0(commander@11.1.0)': dependencies: commander: 11.1.0 - prettier: 3.8.0 + prettier: 3.8.1 '@floating-ui/core@1.7.3': dependencies: @@ -15552,10 +14608,10 @@ snapshots: decimal.js: 10.6.0 tslib: 2.8.1 - '@formatjs/ecma402-abstract@3.0.8': + '@formatjs/ecma402-abstract@3.1.1': dependencies: - '@formatjs/fast-memoize': 3.0.3 - '@formatjs/intl-localematcher': 0.7.5 + '@formatjs/fast-memoize': 3.1.0 + '@formatjs/intl-localematcher': 0.8.1 decimal.js: 10.6.0 tslib: 2.8.1 @@ -15563,7 +14619,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@formatjs/fast-memoize@3.0.3': + '@formatjs/fast-memoize@3.1.0': dependencies: tslib: 2.8.1 @@ -15573,10 +14629,10 @@ snapshots: '@formatjs/icu-skeleton-parser': 1.8.16 tslib: 2.8.1 - '@formatjs/icu-messageformat-parser@3.3.0': + '@formatjs/icu-messageformat-parser@3.5.1': dependencies: - '@formatjs/ecma402-abstract': 3.0.8 - '@formatjs/icu-skeleton-parser': 2.0.8 + '@formatjs/ecma402-abstract': 3.1.1 + '@formatjs/icu-skeleton-parser': 2.1.1 tslib: 2.8.1 '@formatjs/icu-skeleton-parser@1.8.16': @@ -15584,18 +14640,18 @@ snapshots: '@formatjs/ecma402-abstract': 2.3.6 tslib: 2.8.1 - '@formatjs/icu-skeleton-parser@2.0.8': + '@formatjs/icu-skeleton-parser@2.1.1': dependencies: - '@formatjs/ecma402-abstract': 3.0.8 + '@formatjs/ecma402-abstract': 3.1.1 tslib: 2.8.1 '@formatjs/intl-localematcher@0.6.2': dependencies: tslib: 2.8.1 - '@formatjs/intl-localematcher@0.7.5': + '@formatjs/intl-localematcher@0.8.1': dependencies: - '@formatjs/fast-memoize': 3.0.3 + '@formatjs/fast-memoize': 3.1.0 tslib: 2.8.1 '@fortawesome/fontawesome-common-types@7.1.0': {} @@ -15608,10 +14664,10 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.1.0 - '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.23 '@grpc/grpc-js@1.14.3': @@ -15756,22 +14812,22 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.48.0)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.51.5)': dependencies: front-matter: 4.0.2 - marked: 17.0.1 + marked: 17.0.3 node-emoji: 2.2.0 - svelte: 5.48.0 + svelte: 5.51.5 - '@immich/ui@0.62.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)': + '@immich/ui@0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.48.0) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.51.5) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) + bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) luxon: 3.7.2 - simple-icons: 16.4.0 - svelte: 5.48.0 + simple-icons: 16.9.0 + svelte: 5.51.5 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) @@ -15781,8 +14837,6 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/ansi@2.0.3': {} - '@inquirer/checkbox@4.3.2(@types/node@24.10.13)': dependencies: '@inquirer/ansi': 1.0.2 @@ -15793,15 +14847,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/checkbox@5.0.4(@types/node@24.10.13)': - dependencies: - '@inquirer/ansi': 2.0.3 - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/confirm@5.1.21(@types/node@24.10.13)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.10.13) @@ -15809,13 +14854,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/confirm@6.0.4(@types/node@24.10.13)': - dependencies: - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/core@10.3.2(@types/node@24.10.13)': dependencies: '@inquirer/ansi': 1.0.2 @@ -15829,18 +14867,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/core@11.1.1(@types/node@24.10.13)': - dependencies: - '@inquirer/ansi': 2.0.3 - '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@24.10.13) - cli-width: 4.1.0 - mute-stream: 3.0.0 - signal-exit: 4.1.0 - wrap-ansi: 9.0.2 - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/editor@4.2.23(@types/node@24.10.13)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.10.13) @@ -15849,14 +14875,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/editor@5.0.4(@types/node@24.10.13)': - dependencies: - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/external-editor': 2.0.3(@types/node@24.10.13) - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/expand@4.0.23(@types/node@24.10.13)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.10.13) @@ -15865,13 +14883,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/expand@5.0.4(@types/node@24.10.13)': - dependencies: - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/external-editor@1.0.3(@types/node@24.10.13)': dependencies: chardet: 2.1.1 @@ -15879,17 +14890,8 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/external-editor@2.0.3(@types/node@24.10.13)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/figures@1.0.15': {} - '@inquirer/figures@2.0.3': {} - '@inquirer/input@4.3.1(@types/node@24.10.13)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.10.13) @@ -15897,13 +14899,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/input@5.0.4(@types/node@24.10.13)': - dependencies: - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/number@3.0.23(@types/node@24.10.13)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.10.13) @@ -15911,13 +14906,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/number@4.0.4(@types/node@24.10.13)': - dependencies: - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/password@4.0.23(@types/node@24.10.13)': dependencies: '@inquirer/ansi': 1.0.2 @@ -15926,11 +14914,18 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/password@5.0.4(@types/node@24.10.13)': + '@inquirer/prompts@7.10.1(@types/node@24.10.13)': dependencies: - '@inquirer/ansi': 2.0.3 - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/type': 4.0.3(@types/node@24.10.13) + '@inquirer/checkbox': 4.3.2(@types/node@24.10.13) + '@inquirer/confirm': 5.1.21(@types/node@24.10.13) + '@inquirer/editor': 4.2.23(@types/node@24.10.13) + '@inquirer/expand': 4.0.23(@types/node@24.10.13) + '@inquirer/input': 4.3.1(@types/node@24.10.13) + '@inquirer/number': 3.0.23(@types/node@24.10.13) + '@inquirer/password': 4.0.23(@types/node@24.10.13) + '@inquirer/rawlist': 4.1.11(@types/node@24.10.13) + '@inquirer/search': 3.2.2(@types/node@24.10.13) + '@inquirer/select': 4.4.2(@types/node@24.10.13) optionalDependencies: '@types/node': 24.10.13 @@ -15949,21 +14944,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/prompts@8.2.0(@types/node@24.10.13)': - dependencies: - '@inquirer/checkbox': 5.0.4(@types/node@24.10.13) - '@inquirer/confirm': 6.0.4(@types/node@24.10.13) - '@inquirer/editor': 5.0.4(@types/node@24.10.13) - '@inquirer/expand': 5.0.4(@types/node@24.10.13) - '@inquirer/input': 5.0.4(@types/node@24.10.13) - '@inquirer/number': 4.0.4(@types/node@24.10.13) - '@inquirer/password': 5.0.4(@types/node@24.10.13) - '@inquirer/rawlist': 5.2.0(@types/node@24.10.13) - '@inquirer/search': 4.1.0(@types/node@24.10.13) - '@inquirer/select': 5.0.4(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/rawlist@4.1.11(@types/node@24.10.13)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.10.13) @@ -15972,13 +14952,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/rawlist@5.2.0(@types/node@24.10.13)': - dependencies: - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/search@3.2.2(@types/node@24.10.13)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.10.13) @@ -15988,14 +14961,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/search@4.1.0(@types/node@24.10.13)': - dependencies: - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/select@4.4.2(@types/node@24.10.13)': dependencies: '@inquirer/ansi': 1.0.2 @@ -16006,23 +14971,10 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/select@5.0.4(@types/node@24.10.13)': - dependencies: - '@inquirer/ansi': 2.0.3 - '@inquirer/core': 11.1.1(@types/node@24.10.13) - '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@24.10.13) - optionalDependencies: - '@types/node': 24.10.13 - '@inquirer/type@3.0.10(@types/node@24.10.13)': optionalDependencies: '@types/node': 24.10.13 - '@inquirer/type@4.0.3(@types/node@24.10.13)': - optionalDependencies: - '@types/node': 24.10.13 - '@internationalized/date@3.10.0': dependencies: '@swc/helpers': 0.5.17 @@ -16031,7 +14983,7 @@ snapshots: '@isaacs/balanced-match@4.0.1': {} - '@isaacs/brace-expansion@5.0.0': + '@isaacs/brace-expansion@5.0.1': dependencies: '@isaacs/balanced-match': 4.0.1 @@ -16152,8 +15104,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) browserslist: 4.28.1 transitivePeerDependencies: - eslint @@ -16185,17 +15137,9 @@ snapshots: get-stream: 6.0.1 minimist: 1.2.8 - '@mapbox/geojson-types@1.0.2': {} - '@mapbox/jsonlint-lines-primitives@2.0.2': {} - '@mapbox/mapbox-gl-rtl-text@0.2.3(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 - - '@mapbox/mapbox-gl-supported@1.5.0(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 + '@mapbox/mapbox-gl-rtl-text@0.3.0': {} '@mapbox/node-pre-gyp@1.0.11': dependencies: @@ -16206,7 +15150,7 @@ snapshots: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.7.3 + semver: 7.7.4 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -16222,28 +15166,18 @@ snapshots: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.7.3 + semver: 7.7.4 tar: 6.2.1 transitivePeerDependencies: - encoding - supports-color - '@mapbox/point-geometry@0.1.0': {} - '@mapbox/point-geometry@1.1.0': {} - '@mapbox/tiny-sdf@1.2.5': {} - '@mapbox/tiny-sdf@2.0.7': {} - '@mapbox/unitbezier@0.0.0': {} - '@mapbox/unitbezier@0.0.1': {} - '@mapbox/vector-tile@1.3.1': - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile@2.0.4': dependencies: '@mapbox/point-geometry': 1.1.0 @@ -16252,6 +15186,8 @@ snapshots: '@mapbox/whoots-js@3.1.0': {} + '@maplibre/geojson-vt@5.0.4': {} + '@maplibre/maplibre-gl-style-spec@24.4.1': dependencies: '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -16262,17 +15198,17 @@ snapshots: rw: 1.3.3 tinyqueue: 3.0.0 - '@maplibre/mlt@1.1.2': + '@maplibre/mlt@1.1.6': dependencies: '@mapbox/point-geometry': 1.1.0 - '@maplibre/vt-pbf@4.2.0': + '@maplibre/vt-pbf@4.2.1': dependencies: '@mapbox/point-geometry': 1.1.0 '@mapbox/vector-tile': 2.0.4 - '@types/geojson-vt': 3.2.5 + '@maplibre/geojson-vt': 5.0.4 + '@types/geojson': 7946.0.16 '@types/supercluster': 7.1.3 - geojson-vt: 4.0.2 pbf: 4.0.1 supercluster: 8.0.1 @@ -16294,7 +15230,7 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.13 - acorn: 8.15.0 + acorn: 8.16.0 collapse-white-space: 2.1.0 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 @@ -16303,7 +15239,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.15.0) + recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 rehype-recma: 1.0.0 remark-mdx: 3.1.1 @@ -16318,10 +15254,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1)': + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1)': dependencies: '@types/mdx': 2.0.13 - '@types/react': 19.2.8 + '@types/react': 19.2.14 react: 18.3.1 '@mermaid-js/parser@0.6.3': @@ -16350,49 +15286,49 @@ snapshots: '@namnode/store@0.1.0': {} - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(bullmq@5.66.5)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.66.5 + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.68.0 tslib: 2.8.1 - '@nestjs/cli@11.0.15(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@24.10.13)': + '@nestjs/cli@11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.10.13)(chokidar@4.0.3) - '@inquirer/prompts': 8.2.0(@types/node@24.10.13) + '@inquirer/prompts': 7.10.1(@types/node@24.10.13) '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) ansis: 4.2.0 chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17))) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))) glob: 13.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.15.8(@swc/helpers@0.5.17) + '@swc/core': 1.15.11(@swc/helpers@0.5.17) transitivePeerDependencies: - '@types/node' - esbuild - uglify-js - webpack-cli - '@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.0 iterare: 1.2.1 @@ -16407,9 +15343,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -16419,22 +15355,22 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) - '@nestjs/websockets': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + '@nestjs/websockets': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-socket.io@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.3 - '@nestjs/platform-express@11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)': + '@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) - cors: 2.8.5 + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 express: 5.2.1 multer: 2.0.2 path-to-regexp: 8.3.0 @@ -16442,10 +15378,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2)': + '@nestjs/platform-socket.io@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-socket.io@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.3 tslib: 2.8.1 @@ -16454,11 +15390,11 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)': + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) - cron: 4.3.5 + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': dependencies: @@ -16471,14 +15407,14 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.5(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) js-yaml: 4.1.1 - lodash: 4.17.21 + lodash: 4.17.23 path-to-regexp: 8.3.0 reflect-metadata: 0.2.2 swagger-ui-dist: 5.31.0 @@ -16486,25 +15422,25 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.3 - '@nestjs/testing@11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-express@11.1.12)': + '@nestjs/testing@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-express@11.1.13)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) + '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) - '@nestjs/websockets@11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-socket.io@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2) + '@nestjs/platform-socket.io': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(rxjs@7.8.2) '@noble/hashes@1.8.0': {} @@ -16525,306 +15461,306 @@ snapshots: agent-base: 7.1.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - lru-cache: 11.2.4 + lru-cache: 11.2.6 socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color '@npmcli/fs@5.0.0': dependencies: - semver: 7.7.3 + semver: 7.7.4 '@nuxt/opencollective@0.4.1': dependencies: consola: 3.4.2 - '@oazapfts/runtime@1.1.0': {} + '@oazapfts/runtime@1.2.0': {} - '@opentelemetry/api-logs@0.210.0': + '@opentelemetry/api-logs@0.211.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api@1.9.0': {} - '@opentelemetry/configuration@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) yaml: 2.8.2 - '@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-grpc@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.210.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.210.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.210.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.211.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.210.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.211.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-grpc@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-http@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-proto@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-zipkin@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/host-metrics@0.36.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 systeminformation: 5.23.8 - '@opentelemetry/instrumentation-http@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-ioredis@0.58.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-ioredis@0.59.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.210.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common': 0.38.2 - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-nestjs-core@0.56.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-nestjs-core@0.57.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pg@0.62.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.63.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) '@types/pg': 8.15.6 '@types/pg-pool': 2.0.7 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.210.0 + '@opentelemetry/api-logs': 0.211.0 import-in-the-middle: 2.0.0 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.210.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.211.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.210.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.211.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) protobufjs: 8.0.0 - '@opentelemetry/propagator-b3@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common@0.38.2': {} - '@opentelemetry/resources@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/sdk-logs@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.210.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.211.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-metrics@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.210.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-node@0.211.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.210.0 - '@opentelemetry/configuration': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/context-async-hooks': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-grpc': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.210.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/api-logs': 0.211.0 + '@opentelemetry/configuration': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: - supports-color - '@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/sdk-trace-node@2.4.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-node@2.5.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions@1.38.0': {} + '@opentelemetry/semantic-conventions@1.39.0': {} '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.4.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) '@paralleldrive/cuid2@2.3.1': dependencies: @@ -16926,9 +15862,9 @@ snapshots: '@pkgr/core@0.2.9': {} - '@playwright/test@1.57.0': + '@playwright/test@1.58.2': dependencies: - playwright: 1.57.0 + playwright: 1.58.2 '@pnpm/config.env-replace@1.1.0': {} @@ -16967,117 +15903,117 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@react-email/body@0.1.0(react@19.2.3)': + '@react-email/body@0.1.0(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/button@0.2.0(react@19.2.3)': + '@react-email/button@0.2.0(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/code-block@0.1.0(react@19.2.3)': + '@react-email/code-block@0.1.0(react@19.2.4)': dependencies: prismjs: 1.30.0 - react: 19.2.3 + react: 19.2.4 - '@react-email/code-inline@0.0.5(react@19.2.3)': + '@react-email/code-inline@0.0.5(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/column@0.0.13(react@19.2.3)': + '@react-email/column@0.0.13(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/components@0.5.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-email/components@0.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-email/body': 0.1.0(react@19.2.3) - '@react-email/button': 0.2.0(react@19.2.3) - '@react-email/code-block': 0.1.0(react@19.2.3) - '@react-email/code-inline': 0.0.5(react@19.2.3) - '@react-email/column': 0.0.13(react@19.2.3) - '@react-email/container': 0.0.15(react@19.2.3) - '@react-email/font': 0.0.9(react@19.2.3) - '@react-email/head': 0.0.12(react@19.2.3) - '@react-email/heading': 0.0.15(react@19.2.3) - '@react-email/hr': 0.0.11(react@19.2.3) - '@react-email/html': 0.0.11(react@19.2.3) - '@react-email/img': 0.0.11(react@19.2.3) - '@react-email/link': 0.0.12(react@19.2.3) - '@react-email/markdown': 0.0.16(react@19.2.3) - '@react-email/preview': 0.0.13(react@19.2.3) - '@react-email/render': 1.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-email/row': 0.0.12(react@19.2.3) - '@react-email/section': 0.0.16(react@19.2.3) - '@react-email/tailwind': 1.2.2(react@19.2.3) - '@react-email/text': 0.1.5(react@19.2.3) - react: 19.2.3 + '@react-email/body': 0.1.0(react@19.2.4) + '@react-email/button': 0.2.0(react@19.2.4) + '@react-email/code-block': 0.1.0(react@19.2.4) + '@react-email/code-inline': 0.0.5(react@19.2.4) + '@react-email/column': 0.0.13(react@19.2.4) + '@react-email/container': 0.0.15(react@19.2.4) + '@react-email/font': 0.0.9(react@19.2.4) + '@react-email/head': 0.0.12(react@19.2.4) + '@react-email/heading': 0.0.15(react@19.2.4) + '@react-email/hr': 0.0.11(react@19.2.4) + '@react-email/html': 0.0.11(react@19.2.4) + '@react-email/img': 0.0.11(react@19.2.4) + '@react-email/link': 0.0.12(react@19.2.4) + '@react-email/markdown': 0.0.16(react@19.2.4) + '@react-email/preview': 0.0.13(react@19.2.4) + '@react-email/render': 1.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-email/row': 0.0.12(react@19.2.4) + '@react-email/section': 0.0.16(react@19.2.4) + '@react-email/tailwind': 1.2.2(react@19.2.4) + '@react-email/text': 0.1.5(react@19.2.4) + react: 19.2.4 transitivePeerDependencies: - react-dom - '@react-email/container@0.0.15(react@19.2.3)': + '@react-email/container@0.0.15(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/font@0.0.9(react@19.2.3)': + '@react-email/font@0.0.9(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/head@0.0.12(react@19.2.3)': + '@react-email/head@0.0.12(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/heading@0.0.15(react@19.2.3)': + '@react-email/heading@0.0.15(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/hr@0.0.11(react@19.2.3)': + '@react-email/hr@0.0.11(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/html@0.0.11(react@19.2.3)': + '@react-email/html@0.0.11(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/img@0.0.11(react@19.2.3)': + '@react-email/img@0.0.11(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/link@0.0.12(react@19.2.3)': + '@react-email/link@0.0.12(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/markdown@0.0.16(react@19.2.3)': + '@react-email/markdown@0.0.16(react@19.2.4)': dependencies: marked: 15.0.12 - react: 19.2.3 + react: 19.2.4 - '@react-email/preview@0.0.13(react@19.2.3)': + '@react-email/preview@0.0.13(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/render@1.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-email/render@1.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: html-to-text: 9.0.5 - prettier: 3.8.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + prettier: 3.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) react-promise-suspense: 0.3.4 - '@react-email/row@0.0.12(react@19.2.3)': + '@react-email/row@0.0.12(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/section@0.0.16(react@19.2.3)': + '@react-email/section@0.0.16(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/tailwind@1.2.2(react@19.2.3)': + '@react-email/tailwind@1.2.2(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 - '@react-email/text@0.1.5(react@19.2.3)': + '@react-email/text@0.1.5(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 '@replit/codemirror-indentation-markers@6.5.3(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.8)': dependencies: @@ -17205,280 +16141,6 @@ snapshots: micromark-util-character: 1.2.0 micromark-util-symbol: 1.1.0 - '@smithy/abort-controller@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/config-resolver@4.4.6': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - - '@smithy/core@3.20.7': - dependencies: - '@smithy/middleware-serde': 4.2.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.10 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.3.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - tslib: 2.8.1 - - '@smithy/hash-node@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/invalid-dependency@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/is-array-buffer@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/middleware-content-length@4.2.8': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.4.8': - dependencies: - '@smithy/core': 3.20.7 - '@smithy/middleware-serde': 4.2.9 - '@smithy/node-config-provider': 4.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - - '@smithy/middleware-retry@4.4.24': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - - '@smithy/middleware-serde@4.2.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.3.8': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.4.8': - dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/property-provider@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/protocol-http@5.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/querystring-builder@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-uri-escape': 4.2.0 - tslib: 2.8.1 - - '@smithy/querystring-parser@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/service-error-classification@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - - '@smithy/shared-ini-file-loader@4.4.3': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/signature-v4@5.3.8': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/smithy-client@4.10.9': - dependencies: - '@smithy/core': 3.20.7 - '@smithy/middleware-endpoint': 4.4.8 - '@smithy/middleware-stack': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.10 - tslib: 2.8.1 - - '@smithy/types@4.12.0': - dependencies: - tslib: 2.8.1 - - '@smithy/url-parser@4.2.8': - dependencies: - '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-base64@4.3.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-body-length-browser@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-body-length-node@4.2.1': - dependencies: - tslib: 2.8.1 - - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-buffer-from@4.2.0': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-config-provider@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-defaults-mode-browser@4.3.23': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-defaults-mode-node@4.2.26': - dependencies: - '@smithy/config-resolver': 4.4.6 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.10.9 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-endpoints@3.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-hex-encoding@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-middleware@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-retry@4.2.8': - dependencies: - '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-stream@4.5.10': - dependencies: - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-uri-escape@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-utf8@4.2.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - tslib: 2.8.1 - - '@smithy/uuid@1.1.0': - dependencies: - tslib: 2.8.1 - '@socket.io/component-emitter@3.1.2': {} '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.6)': @@ -17494,68 +16156,67 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)': + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.48.0 - svelte-parse-markup: 0.1.5(svelte@5.48.0) - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + svelte: 5.51.5 + svelte-parse-markup: 0.1.5(svelte@5.51.5) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.2(rollup@4.55.1) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 - acorn: 8.15.0 + acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.6.2 + devalue: 5.6.3 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 mrmime: 2.0.1 - sade: 1.8.1 - set-cookie-parser: 2.7.2 + set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.48.0 - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + svelte: 5.51.5 + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.48.0 - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + svelte: 5.51.5 + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.48.0 - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + svelte: 5.51.5 + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -17652,51 +16313,51 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.15.8': + '@swc/core-darwin-arm64@1.15.11': optional: true - '@swc/core-darwin-x64@1.15.8': + '@swc/core-darwin-x64@1.15.11': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.8': + '@swc/core-linux-arm-gnueabihf@1.15.11': optional: true - '@swc/core-linux-arm64-gnu@1.15.8': + '@swc/core-linux-arm64-gnu@1.15.11': optional: true - '@swc/core-linux-arm64-musl@1.15.8': + '@swc/core-linux-arm64-musl@1.15.11': optional: true - '@swc/core-linux-x64-gnu@1.15.8': + '@swc/core-linux-x64-gnu@1.15.11': optional: true - '@swc/core-linux-x64-musl@1.15.8': + '@swc/core-linux-x64-musl@1.15.11': optional: true - '@swc/core-win32-arm64-msvc@1.15.8': + '@swc/core-win32-arm64-msvc@1.15.11': optional: true - '@swc/core-win32-ia32-msvc@1.15.8': + '@swc/core-win32-ia32-msvc@1.15.11': optional: true - '@swc/core-win32-x64-msvc@1.15.8': + '@swc/core-win32-x64-msvc@1.15.11': optional: true - '@swc/core@1.15.8(@swc/helpers@0.5.17)': + '@swc/core@1.15.11(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.8 - '@swc/core-darwin-x64': 1.15.8 - '@swc/core-linux-arm-gnueabihf': 1.15.8 - '@swc/core-linux-arm64-gnu': 1.15.8 - '@swc/core-linux-arm64-musl': 1.15.8 - '@swc/core-linux-x64-gnu': 1.15.8 - '@swc/core-linux-x64-musl': 1.15.8 - '@swc/core-win32-arm64-msvc': 1.15.8 - '@swc/core-win32-ia32-msvc': 1.15.8 - '@swc/core-win32-x64-msvc': 1.15.8 + '@swc/core-darwin-arm64': 1.15.11 + '@swc/core-darwin-x64': 1.15.11 + '@swc/core-linux-arm-gnueabihf': 1.15.11 + '@swc/core-linux-arm64-gnu': 1.15.11 + '@swc/core-linux-arm64-musl': 1.15.11 + '@swc/core-linux-x64-gnu': 1.15.11 + '@swc/core-linux-x64-musl': 1.15.11 + '@swc/core-win32-arm64-msvc': 1.15.11 + '@swc/core-win32-ia32-msvc': 1.15.11 + '@swc/core-win32-x64-msvc': 1.15.11 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -17716,7 +16377,7 @@ snapshots: '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 jiti: 2.6.1 lightningcss: 1.30.2 magic-string: 0.30.21 @@ -17774,16 +16435,16 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@babel/runtime': 7.28.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -17801,18 +16462,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.48.0)': + '@testing-library/svelte-core@1.0.0(svelte@5.51.5)': dependencies: - svelte: 5.48.0 + svelte: 5.51.5 - '@testing-library/svelte@5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.48.0) - svelte: 5.48.0 + '@testing-library/svelte-core': 1.0.0(svelte@5.51.5) + svelte: 5.51.5 optionalDependencies: - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17827,9 +16488,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/once@2.0.0': - optional: true - '@trysound/sax@0.2.0': {} '@turf/boolean-point-in-polygon@7.3.2': @@ -18122,10 +16780,6 @@ snapshots: dependencies: '@types/node': 24.10.13 - '@types/geojson-vt@3.2.5': - dependencies: - '@types/geojson': 7946.0.16 - '@types/geojson@7946.0.16': {} '@types/gtag.js@0.0.12': {} @@ -18243,25 +16897,18 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@20.19.30': - dependencies: - undici-types: 6.21.0 - '@types/node@24.10.13': dependencies: undici-types: 7.16.0 - '@types/node@25.0.9': + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 optional: true - '@types/nodemailer@7.0.5': + '@types/nodemailer@7.0.9': dependencies: - '@aws-sdk/client-sesv2': 3.971.0 '@types/node': 24.10.13 - transitivePeerDependencies: - - aws-crt '@types/oidc-provider@9.5.0': dependencies: @@ -18306,21 +16953,21 @@ snapshots: '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.8 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.8 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.8 + '@types/react': 19.2.14 - '@types/react@19.2.8': + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -18401,8 +17048,7 @@ snapshots: dependencies: '@types/node': 24.10.13 - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/ua-parser-js@0.7.39': {} @@ -18424,14 +17070,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -18440,41 +17086,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.53.0': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -18482,44 +17128,44 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.53.0': {} + '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.53.0': + '@typescript-eslint/visitor-keys@8.55.0': dependencies: - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -18534,11 +17180,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -18553,7 +17199,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -18573,13 +17219,13 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -18687,17 +17333,14 @@ snapshots: '@xtuc/long@4.2.2': {} - '@zoom-image/core@0.41.4': + '@zoom-image/core@0.42.0': dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.8(svelte@5.48.0)': + '@zoom-image/svelte@0.3.9(svelte@5.51.5)': dependencies: - '@zoom-image/core': 0.41.4 - svelte: 5.48.0 - - abab@2.0.6: - optional: true + '@zoom-image/core': 0.42.0 + svelte: 5.51.5 abbrev@1.1.1: {} @@ -18717,29 +17360,23 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-globals@7.0.1: + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: - acorn: 8.15.0 - acorn-walk: 8.3.4 - optional: true + acorn: 8.16.0 - acorn-import-attributes@1.9.5(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 - - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk@8.3.4: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} address@1.2.2: {} @@ -18764,9 +17401,9 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.2.1 - ajv-formats@2.1.1(ajv@8.17.1): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: @@ -18776,9 +17413,9 @@ snapshots: dependencies: ajv: 6.12.6 - ajv-keywords@5.1.0(ajv@8.17.1): + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 fast-deep-equal: 3.1.3 ajv@6.12.6: @@ -18795,6 +17432,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + algoliasearch-helper@3.26.1(algoliasearch@5.46.0): dependencies: '@algolia/events': 4.0.1 @@ -18936,10 +17580,10 @@ snapshots: dependencies: immediate: 3.3.0 - autoprefixer@10.4.23(postcss@8.5.6): + autoprefixer@10.4.24(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001760 + caniuse-lite: 1.0.30001769 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -19031,7 +17675,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.9.7: {} + baseline-browser-mapping@2.9.19: {} batch-cluster@16.0.0: {} @@ -19046,7 +17690,7 @@ snapshots: bcrypt@6.0.0: dependencies: node-addon-api: 8.5.0 - node-gyp: 12.1.0 + node-gyp: 12.2.0 node-gyp-build: 4.8.4 transitivePeerDependencies: - supports-color @@ -19055,15 +17699,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0): + bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) - svelte: 5.48.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + svelte: 5.51.5 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -19112,8 +17756,6 @@ snapshots: boolbase@1.0.0: {} - bowser@2.13.1: {} - boxen@6.2.1: dependencies: ansi-align: 3.0.1 @@ -19151,11 +17793,11 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.7 - caniuse-lite: 1.0.30001760 - electron-to-chromium: 1.5.267 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 node-releases: 2.0.27 - update-browserslist-db: 1.2.2(browserslist@4.28.1) + update-browserslist-db: 1.2.3(browserslist@4.28.1) buffer-crc32@1.0.0: {} @@ -19178,10 +17820,10 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.66.5: + bullmq@5.68.0: dependencies: cron-parser: 4.9.0 - ioredis: 5.9.1 + ioredis: 5.9.2 msgpackr: 1.11.5 node-abort-controller: 3.1.1 semver: 7.7.3 @@ -19212,14 +17854,14 @@ snapshots: dependencies: '@npmcli/fs': 5.0.0 fs-minipass: 3.0.3 - glob: 13.0.0 - lru-cache: 11.2.4 + glob: 13.0.2 + lru-cache: 11.2.6 minipass: 7.1.2 minipass-collect: 2.0.1 minipass-flush: 1.0.5 minipass-pipeline: 1.2.4 p-map: 7.0.4 - ssri: 13.0.0 + ssri: 13.0.1 unique-filename: 5.0.0 cacheable-lookup@7.0.0: {} @@ -19269,11 +17911,11 @@ snapshots: caniuse-api@3.0.0: dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001760 + caniuse-lite: 1.0.30001769 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001760: {} + caniuse-lite@1.0.30001769: {} canvas@2.11.2: dependencies: @@ -19641,7 +18283,7 @@ snapshots: core-util-is@1.0.3: {} - cors@2.8.5: + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 @@ -19682,7 +18324,7 @@ snapshots: dependencies: luxon: 3.7.2 - cron@4.3.5: + cron@4.4.0: dependencies: '@types/luxon': 3.7.1 luxon: 3.7.2 @@ -19722,7 +18364,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.6) postcss-modules-values: 4.0.0(postcss@8.5.6) postcss-value-parser: 4.2.0 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: webpack: 5.104.1 @@ -19774,15 +18416,13 @@ snapshots: css.escape@1.5.1: {} - csscolorparser@1.0.3: {} - cssdb@8.5.2: {} cssesc@3.0.0: {} cssnano-preset-advanced@6.1.2(postcss@8.5.6): dependencies: - autoprefixer: 10.4.23(postcss@8.5.6) + autoprefixer: 10.4.24(postcss@8.5.6) browserslist: 4.28.1 cssnano-preset-default: 6.1.2(postcss@8.5.6) postcss: 8.5.6 @@ -19839,17 +18479,6 @@ snapshots: dependencies: css-tree: 2.2.1 - cssom@0.3.8: - optional: true - - cssom@0.5.0: - optional: true - - cssstyle@2.3.0: - dependencies: - cssom: 0.3.8 - optional: true - cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -20047,13 +18676,6 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 - data-urls@3.0.2: - dependencies: - abab: 2.0.6 - whatwg-mimetype: 3.0.0 - whatwg-url: 11.0.0 - optional: true - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -20168,7 +18790,7 @@ snapshots: transitivePeerDependencies: - supports-color - devalue@5.6.2: {} + devalue@5.6.3: {} devlop@1.1.0: dependencies: @@ -20226,9 +18848,9 @@ snapshots: transitivePeerDependencies: - supports-color - docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.8)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) autocomplete.js: 0.37.1 clsx: 2.1.1 gauge: 3.0.2 @@ -20270,11 +18892,6 @@ snapshots: domelementtype@2.3.0: {} - domexception@4.0.0: - dependencies: - webidl-conversions: 7.0.0 - optional: true - domhandler@4.3.1: dependencies: domelementtype: 2.3.0 @@ -20308,7 +18925,7 @@ snapshots: dependencies: is-obj: 2.0.0 - dotenv@17.2.3: {} + dotenv@17.2.4: {} dunder-proto@1.0.1: dependencies: @@ -20318,8 +18935,6 @@ snapshots: duplexer@0.1.2: {} - earcut@2.2.4: {} - earcut@3.0.2: {} eastasianwidth@0.2.0: {} @@ -20330,7 +18945,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.286: {} emoji-regex@10.6.0: {} @@ -20376,7 +18991,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 - cors: 2.8.5 + cors: 2.8.6 debug: 4.4.3 engine.io-parser: 5.2.3 ws: 8.18.3 @@ -20385,7 +19000,7 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.18.4: + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -20458,7 +19073,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.15.0 + acorn: 8.16.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 @@ -20517,34 +19132,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.2: + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 escalade@3.2.0: {} @@ -20558,42 +19173,33 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - optional: true - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-compat@6.0.2(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-compat@6.1.0(eslint@9.39.2(jiti@2.6.1)): dependencies: - '@mdn/browser-compat-data': 5.7.6 + '@mdn/browser-compat-data': 6.1.5 ast-metadata-inferer: 0.8.1 browserslist: 4.28.1 - caniuse-lite: 1.0.30001760 + caniuse-lite: 1.0.30001769 eslint: 9.39.2(jiti@2.6.1) find-up: 5.0.0 globals: 15.15.0 lodash.memoize: 4.1.2 - semver: 7.7.3 + semver: 7.7.4 - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.0): + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): dependencies: eslint: 9.39.2(jiti@2.6.1) - prettier: 3.8.0 + prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0): + eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.51.5): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -20604,10 +19210,10 @@ snapshots: postcss: 8.5.6 postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) - semver: 7.7.3 - svelte-eslint-parser: 1.4.1(svelte@5.48.0) + semver: 7.7.4 + svelte-eslint-parser: 1.4.1(svelte@5.51.5) optionalDependencies: - svelte: 5.48.0 + svelte: 5.51.5 transitivePeerDependencies: - ts-node @@ -20630,7 +19236,7 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.0 - semver: 7.7.3 + semver: 7.7.4 strip-indent: 4.1.1 eslint-scope@5.1.1: @@ -20699,8 +19305,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -20709,7 +19315,7 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.1: + esrap@2.2.3: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -20905,10 +19511,10 @@ snapshots: extend@3.0.2: {} - fabric@6.9.1: + fabric@7.2.0: optionalDependencies: canvas: 2.11.2 - jsdom: 20.0.3(canvas@2.11.2) + jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - bufferutil - encoding @@ -20944,10 +19550,6 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-parser@5.2.5: - dependencies: - strnum: 2.1.2 - fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -21067,9 +19669,9 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17))): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))): dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 chalk: 4.1.2 chokidar: 4.0.3 cosmiconfig: 8.3.6(typescript@5.9.3) @@ -21079,10 +19681,10 @@ snapshots: minimatch: 3.1.2 node-abort-controller: 3.1.1 schema-utils: 3.3.0 - semver: 7.7.3 + semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)) form-data-encoder@2.1.4: {} @@ -21179,10 +19781,6 @@ snapshots: pbf: 3.3.0 shapefile: 0.6.6 - geojson-vt@3.2.1: {} - - geojson-vt@4.0.2: {} - geojson@0.5.0: {} get-caller-file@2.0.5: {} @@ -21248,14 +19846,20 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.1.1 + minimatch: 10.1.2 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.1 glob@13.0.0: dependencies: - minimatch: 10.1.1 + minimatch: 10.1.2 + minipass: 7.1.2 + path-scurry: 2.0.1 + + glob@13.0.2: + dependencies: + minimatch: 10.1.2 minipass: 7.1.2 path-scurry: 2.0.1 @@ -21326,8 +19930,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - grid-index@1.1.0: {} - gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -21345,11 +19947,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.3.0: + happy-dom@20.6.1: dependencies: - '@types/node': 20.19.30 + '@types/node': 24.10.13 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 + entities: 6.0.1 whatwg-mimetype: 3.0.0 ws: 8.19.0 transitivePeerDependencies: @@ -21549,11 +20152,6 @@ snapshots: readable-stream: 2.3.8 wbuf: 1.7.3 - html-encoding-sniffer@3.0.0: - dependencies: - whatwg-encoding: 2.0.0 - optional: true - html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -21651,15 +20249,6 @@ snapshots: http-parser-js@0.5.10: {} - http-proxy-agent@5.0.0: - dependencies: - '@tootallnate/once': 2.0.0 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -21753,8 +20342,8 @@ snapshots: import-in-the-middle@2.0.0: dependencies: - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 @@ -21814,18 +20403,18 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.4 tslib: 2.8.1 - intl-messageformat@11.0.9: + intl-messageformat@11.1.2: dependencies: - '@formatjs/ecma402-abstract': 3.0.8 - '@formatjs/fast-memoize': 3.0.3 - '@formatjs/icu-messageformat-parser': 3.3.0 + '@formatjs/ecma402-abstract': 3.1.1 + '@formatjs/fast-memoize': 3.1.0 + '@formatjs/icu-messageformat-parser': 3.5.1 tslib: 2.8.1 invariant@2.2.4: dependencies: loose-envify: 1.4.0 - ioredis@5.9.1: + ioredis@5.9.2: dependencies: '@ioredis/commands': 1.5.0 cluster-key-slot: 1.1.2 @@ -21968,7 +20557,7 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.1: {} + isexe@4.0.0: {} isobject@3.0.1: {} @@ -22060,42 +20649,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@20.0.3(canvas@2.11.2): - dependencies: - abab: 2.0.6 - acorn: 8.15.0 - acorn-globals: 7.0.1 - cssom: 0.5.0 - cssstyle: 2.3.0 - data-urls: 3.0.2 - decimal.js: 10.6.0 - domexception: 4.0.0 - escodegen: 2.1.0 - form-data: 4.0.5 - html-encoding-sniffer: 3.0.0 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 - parse5: 7.3.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 4.1.4 - w3c-xmlserializer: 4.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 2.0.0 - whatwg-mimetype: 3.0.0 - whatwg-url: 11.0.0 - ws: 8.19.0 - xml-name-validator: 4.0.0 - optionalDependencies: - canvas: 2.11.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)): dependencies: cssstyle: 4.6.0 @@ -22205,7 +20758,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.3 + semver: 7.7.4 just-compare@2.3.0: {} @@ -22226,8 +20779,6 @@ snapshots: dependencies: commander: 8.3.0 - kdbush@3.0.0: {} - kdbush@4.0.2: {} keygrip@1.1.0: @@ -22428,8 +20979,6 @@ snapshots: lodash.uniq@4.5.0: {} - lodash@4.17.21: {} - lodash@4.17.23: {} log-symbols@4.1.0: @@ -22465,7 +21014,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.4: {} + lru-cache@11.2.6: {} lru-cache@5.1.1: dependencies: @@ -22503,7 +21052,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 make-fetch-happen@15.0.3: dependencies: @@ -22511,42 +21060,17 @@ snapshots: cacache: 20.0.3 http-cache-semantics: 4.2.0 minipass: 7.1.2 - minipass-fetch: 5.0.0 + minipass-fetch: 5.0.1 minipass-flush: 1.0.5 minipass-pipeline: 1.2.4 negotiator: 1.0.0 proc-log: 6.1.0 promise-retry: 2.0.1 - ssri: 13.0.0 + ssri: 13.0.1 transitivePeerDependencies: - supports-color - mapbox-gl@1.13.3: - 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-gl@1.13.3) - '@mapbox/point-geometry': 0.1.0 - '@mapbox/tiny-sdf': 1.2.5 - '@mapbox/unitbezier': 0.0.0 - '@mapbox/vector-tile': 1.3.1 - '@mapbox/whoots-js': 3.1.0 - csscolorparser: 1.0.3 - earcut: 2.2.4 - geojson-vt: 3.2.1 - gl-matrix: 3.4.4 - grid-index: 1.1.0 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 1.0.2 - quickselect: 2.0.0 - rw: 1.3.3 - supercluster: 7.1.5 - tinyqueue: 2.0.3 - vt-pbf: 3.1.3 - - maplibre-gl@5.16.0: + maplibre-gl@5.18.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -22555,14 +21079,13 @@ snapshots: '@mapbox/unitbezier': 0.0.1 '@mapbox/vector-tile': 2.0.4 '@mapbox/whoots-js': 3.1.0 + '@maplibre/geojson-vt': 5.0.4 '@maplibre/maplibre-gl-style-spec': 24.4.1 - '@maplibre/mlt': 1.1.2 - '@maplibre/vt-pbf': 4.2.0 + '@maplibre/mlt': 1.1.6 + '@maplibre/vt-pbf': 4.2.1 '@types/geojson': 7946.0.16 - '@types/geojson-vt': 3.2.5 '@types/supercluster': 7.1.3 earcut: 3.0.2 - geojson-vt: 4.0.2 gl-matrix: 3.4.4 kdbush: 4.0.2 murmurhash-js: 1.0.0 @@ -22586,7 +21109,7 @@ snapshots: marked@16.4.2: {} - marked@17.0.1: {} + marked@17.0.3: {} math-intrinsics@1.1.0: {} @@ -22981,8 +21504,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -23188,9 +21711,9 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.1.1: + minimatch@10.1.2: dependencies: - '@isaacs/brace-expansion': 5.0.0 + '@isaacs/brace-expansion': 5.0.1 minimatch@3.1.2: dependencies: @@ -23210,10 +21733,10 @@ snapshots: dependencies: minipass: 7.1.2 - minipass-fetch@5.0.0: + minipass-fetch@5.0.1: dependencies: minipass: 7.1.2 - minipass-sized: 1.0.3 + minipass-sized: 2.0.0 minizlib: 3.1.0 optionalDependencies: encoding: 0.1.13 @@ -23226,9 +21749,9 @@ snapshots: dependencies: minipass: 3.3.6 - minipass-sized@1.0.3: + minipass-sized@2.0.0: dependencies: - minipass: 3.3.6 + minipass: 7.1.2 minipass@3.3.6: dependencies: @@ -23259,7 +21782,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.2 @@ -23319,8 +21842,6 @@ snapshots: mute-stream@2.0.0: {} - mute-stream@3.0.0: {} - mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -23353,12 +21874,12 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@types/inquirer@8.2.12)(@types/node@24.10.13)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@types/inquirer@8.2.12)(@types/node@24.10.13)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) - '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12) - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/inquirer': 8.2.12 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) @@ -23367,25 +21888,25 @@ snapshots: - '@types/node' - typescript - nestjs-cls@5.4.3(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@5.4.3(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - nestjs-kysely@3.1.2(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(kysely@0.28.2)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(kysely@0.28.2)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) kysely: 0.28.2 reflect-metadata: 0.2.2 tslib: 2.8.1 - nestjs-otel@7.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12): + nestjs-otel@7.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13): dependencies: - '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.36.2(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -23438,7 +21959,7 @@ snapshots: node-gyp-build@4.8.4: {} - node-gyp@12.1.0: + node-gyp@12.2.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 @@ -23446,16 +21967,16 @@ snapshots: make-fetch-happen: 15.0.3 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.3 - tar: 7.5.2 + semver: 7.7.4 + tar: 7.5.7 tinyglobby: 0.2.15 - which: 6.0.0 + which: 6.0.1 transitivePeerDependencies: - supports-color node-releases@2.0.27: {} - nodemailer@7.0.12: {} + nodemailer@7.0.13: {} nopt@1.0.10: dependencies: @@ -23511,7 +22032,7 @@ snapshots: pkg-types: 2.3.0 tinyexec: 0.3.2 - oauth4webapi@3.8.3: {} + oauth4webapi@3.8.5: {} object-assign@4.1.1: {} @@ -23584,10 +22105,10 @@ snapshots: opener@1.5.2: {} - openid-client@6.8.1: + openid-client@6.8.2: dependencies: jose: 6.1.3 - oauth4webapi: 3.8.3 + oauth4webapi: 3.8.5 optionator@0.9.4: dependencies: @@ -23680,7 +22201,7 @@ snapshots: got: 12.6.1 registry-auth-token: 5.1.0 registry-url: 6.0.1 - semver: 7.7.3 + semver: 7.7.4 package-manager-detector@1.6.0: {} @@ -23705,7 +22226,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -23758,7 +22279,7 @@ snapshots: path-scurry@2.0.1: dependencies: - lru-cache: 11.2.4 + lru-cache: 11.2.6 minipass: 7.1.2 path-source@0.1.3: @@ -23796,13 +22317,13 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.10.0: {} + pg-connection-string@2.11.0: {} pg-int8@1.0.1: {} - pg-pool@3.11.0(pg@8.17.1): + pg-pool@3.11.0(pg@8.18.0): dependencies: - pg: 8.17.1 + pg: 8.18.0 pg-protocol@1.11.0: {} @@ -23814,10 +22335,10 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.17.1: + pg@8.18.0: dependencies: - pg-connection-string: 2.10.0 - pg-pool: 3.11.0(pg@8.17.1) + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) pg-protocol: 1.11.0 pg-types: 2.2.0 pgpass: 1.0.5 @@ -23856,11 +22377,11 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - playwright-core@1.57.0: {} + playwright-core@1.58.2: {} - playwright@1.57.0: + playwright@1.58.2: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 @@ -23871,7 +22392,7 @@ snapshots: '@types/leaflet': 1.9.21 fflate: 0.8.2 - pmtiles@4.3.2: + pmtiles@4.4.0: dependencies: fflate: 0.8.2 @@ -24065,7 +22586,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@5.9.3) jiti: 1.21.7 postcss: 8.5.6 - semver: 7.7.3 + semver: 7.7.4 webpack: 5.104.1 transitivePeerDependencies: - typescript @@ -24261,7 +22782,7 @@ snapshots: '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.6) '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.6) '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.6) - autoprefixer: 10.4.23(postcss@8.5.6) + autoprefixer: 10.4.24(postcss@8.5.6) browserslist: 4.28.1 css-blank-pseudo: 7.0.1(postcss@8.5.6) css-has-pseudo: 7.0.3(postcss@8.5.6) @@ -24382,8 +22903,6 @@ snapshots: postgres@3.4.8: {} - potpack@1.0.2: {} - potpack@2.1.0: {} prelude-ls@1.2.1: {} @@ -24392,21 +22911,21 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier-plugin-organize-imports@4.3.0(prettier@3.8.0)(typescript@5.9.3): + prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): dependencies: - prettier: 3.8.0 + prettier: 3.8.1 typescript: 5.9.3 - prettier-plugin-sort-json@4.2.0(prettier@3.8.0): + prettier-plugin-sort-json@4.2.0(prettier@3.8.1): dependencies: - prettier: 3.8.0 + prettier: 3.8.1 - prettier-plugin-svelte@3.4.1(prettier@3.8.0)(svelte@5.48.0): + prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.51.5): dependencies: - prettier: 3.8.0 - svelte: 5.48.0 + prettier: 3.8.1 + svelte: 5.51.5 - prettier@3.8.0: {} + prettier@3.8.1: {} pretty-error@4.0.0: dependencies: @@ -24506,11 +23025,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - optional: true - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -24534,17 +23048,12 @@ snapshots: dependencies: side-channel: 1.1.0 - querystringify@2.2.0: - optional: true - queue-microtask@1.2.3: {} quick-lru@5.1.1: {} quick-lru@7.3.0: {} - quickselect@2.0.0: {} - quickselect@3.0.0: {} railroad-diagrams@1.0.0: {} @@ -24595,9 +23104,9 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-dom@19.2.3(react@19.2.3): + react-dom@19.2.4(react@19.2.4): dependencies: - react: 19.2.3 + react: 19.2.4 scheduler: 0.27.0 react-email@4.3.2: @@ -24677,7 +23186,7 @@ snapshots: dependencies: loose-envify: 1.4.0 - react@19.2.3: {} + react@19.2.4: {} read-cache@1.0.0: dependencies: @@ -24723,10 +23232,10 @@ snapshots: estree-util-build-jsx: 3.0.1 vfile: 6.0.3 - recma-jsx@1.0.1(acorn@8.15.0): + recma-jsx@1.0.1(acorn@8.16.0): dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 @@ -25031,14 +23540,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0): + runed@0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.48.0 + svelte: 5.51.5 optionalDependencies: - '@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -25105,9 +23614,9 @@ snapshots: schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) + ajv-keywords: 5.1.0(ajv@8.18.0) search-insights@2.17.3: {} @@ -25129,12 +23638,14 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 semver@6.3.1: {} semver@7.7.3: {} + semver@7.7.4: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -25215,7 +23726,7 @@ snapshots: set-blocking@2.0.0: {} - set-cookie-parser@2.7.2: {} + set-cookie-parser@3.0.1: {} set-function-length@1.2.2: dependencies: @@ -25250,8 +23761,8 @@ snapshots: '@img/colour': 1.0.0 detect-libc: 2.1.2 node-addon-api: 8.5.0 - node-gyp: 12.1.0 - semver: 7.7.3 + node-gyp: 12.2.0 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -25334,7 +23845,7 @@ snapshots: simple-icons@15.22.0: {} - simple-icons@16.4.0: {} + simple-icons@16.9.0: {} sirv@2.0.4: dependencies: @@ -25405,7 +23916,7 @@ snapshots: dependencies: accepts: 1.3.8 base64id: 2.0.0 - cors: 2.8.5 + cors: 2.8.6 debug: 4.4.3 engine.io: 6.6.5 socket.io-adapter: 2.5.6 @@ -25500,7 +24011,7 @@ snapshots: cpu-features: 0.0.10 nan: 2.24.0 - ssri@13.0.0: + ssri@13.0.1: dependencies: minipass: 7.1.2 @@ -25594,8 +24105,6 @@ snapshots: dependencies: js-tokens: 9.0.1 - strnum@2.1.2: {} - strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 @@ -25642,10 +24151,6 @@ snapshots: transitivePeerDependencies: - supports-color - supercluster@7.1.5: - dependencies: - kdbush: 3.0.0 - supercluster@8.0.1: dependencies: kdbush: 4.0.2 @@ -25668,23 +24173,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.48.0): + svelte-awesome@3.3.5(svelte@5.51.5): dependencies: - svelte: 5.48.0 + svelte: 5.51.5 - svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3): + svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.51.5)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.48.0 + svelte: 5.51.5 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.48.0): + svelte-eslint-parser@1.4.1(svelte@5.51.5): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -25693,7 +24198,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.48.0 + svelte: 5.51.5 svelte-floating-ui@1.5.8: dependencies: @@ -25706,7 +24211,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.48.0): + svelte-i18n@4.0.1(svelte@5.51.5): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -25714,10 +24219,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.48.0 + svelte: 5.51.5 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.48.0): + svelte-jsoneditor@3.11.0(svelte@5.51.5): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -25732,7 +24237,7 @@ snapshots: '@jsonquerylang/jsonquery': 5.1.1 '@lezer/highlight': 1.2.3 '@replit/codemirror-indentation-markers': 6.5.3(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.8) - ajv: 8.17.1 + ajv: 8.18.0 codemirror-wrapped-line-indent: 1.0.9(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.8) diff-sequences: 29.6.3 immutable-json-patch: 6.0.2 @@ -25744,54 +24249,55 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.48.0 - svelte-awesome: 3.3.5(svelte@5.48.0) + svelte: 5.51.5 + svelte-awesome: 3.3.5(svelte@5.51.5) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.5(svelte@5.48.0): + svelte-maplibre@1.2.6(svelte@5.51.5): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.16.0 + maplibre-gl: 5.18.0 pmtiles: 3.2.1 - svelte: 5.48.0 + svelte: 5.51.5 - svelte-parse-markup@0.1.5(svelte@5.48.0): + svelte-parse-markup@0.1.5(svelte@5.51.5): dependencies: - svelte: 5.48.0 + svelte: 5.51.5 - svelte-persisted-store@0.12.0(svelte@5.48.0): + svelte-persisted-store@0.12.0(svelte@5.51.5): dependencies: - svelte: 5.48.0 + svelte: 5.51.5 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) style-to-object: 1.0.14 - svelte: 5.48.0 + svelte: 5.51.5 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.48.0: + svelte@5.51.5: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) '@types/estree': 1.0.8 - acorn: 8.15.0 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.6.2 + devalue: 5.6.3 esm-env: 1.2.2 - esrap: 2.2.1 + esrap: 2.2.3 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -25931,7 +24437,7 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - tar@7.5.2: + tar@7.5.7: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -25939,16 +24445,16 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.3.16(@swc/core@1.15.8(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17))): + terser-webpack-plugin@5.3.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.15.8(@swc/helpers@0.5.17) + '@swc/core': 1.15.11(@swc/helpers@0.5.17) terser-webpack-plugin@5.3.16(webpack@5.104.1): dependencies: @@ -25962,7 +24468,7 @@ snapshots: terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -26054,8 +24560,6 @@ snapshots: tinypool@1.1.1: {} - tinyqueue@2.0.3: {} - tinyqueue@3.0.0: {} tinyrainbow@2.0.0: {} @@ -26091,14 +24595,6 @@ snapshots: totalist@3.0.1: {} - tough-cookie@4.1.4: - dependencies: - psl: 1.15.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - optional: true - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -26106,11 +24602,6 @@ snapshots: tr46@0.0.3: {} - tr46@3.0.0: - dependencies: - punycode: 2.3.1 - optional: true - tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -26147,7 +24638,7 @@ snapshots: tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 tapable: 2.3.0 tsconfig-paths: 4.2.0 @@ -26163,7 +24654,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.2 + esbuild: 0.27.3 get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 @@ -26199,12 +24690,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -26214,7 +24705,7 @@ snapshots: ua-is-frozen@0.1.2: {} - ua-parser-js@2.0.8: + ua-parser-js@2.0.9: dependencies: detect-europe-js: 0.1.2 is-standalone-pwa: 0.1.1 @@ -26235,8 +24726,6 @@ snapshots: undici-types@5.26.5: {} - undici-types@6.21.0: {} - undici-types@7.16.0: {} undici@7.18.0: {} @@ -26334,17 +24823,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - universalify@0.2.0: - optional: true - universalify@2.0.1: {} unpipe@1.0.0: {} - unplugin-swc@1.5.9(@swc/core@1.15.8(@swc/helpers@0.5.17))(rollup@4.55.1): + unplugin-swc@1.5.9(@swc/core@1.15.11(@swc/helpers@0.5.17))(rollup@4.55.1): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.55.1) - '@swc/core': 1.15.8(@swc/helpers@0.5.17) + '@swc/core': 1.15.11(@swc/helpers@0.5.17) load-tsconfig: 0.2.5 unplugin: 2.3.11 transitivePeerDependencies: @@ -26353,11 +24839,11 @@ snapshots: unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 + acorn: 8.16.0 picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - update-browserslist-db@1.2.2(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 @@ -26376,7 +24862,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.7.3 + semver: 7.7.4 semver-diff: 4.0.0 xdg-basedir: 5.1.0 @@ -26395,12 +24881,6 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@5.104.1) - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - optional: true - url@0.11.4: dependencies: punycode: 1.4.1 @@ -26505,13 +24985,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -26526,12 +25006,11 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.0.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - optionalDependencies: vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -26539,7 +25018,7 @@ snapshots: vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - esbuild: 0.27.2 + esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 @@ -26555,16 +25034,16 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - esbuild: 0.27.2 + esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.0.9 + '@types/node': 25.2.3 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -26573,15 +25052,15 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -26609,7 +25088,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.3.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -26625,7 +25104,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -26653,7 +25132,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.3.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -26669,11 +25148,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -26691,13 +25170,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 25.0.9 - happy-dom: 20.3.0 + '@types/node': 25.2.3 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -26730,19 +25209,8 @@ snapshots: vscode-uri@3.0.8: {} - vt-pbf@3.1.3: - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile': 1.3.1 - pbf: 3.3.0 - w3c-keyname@2.2.8: {} - w3c-xmlserializer@4.0.0: - dependencies: - xml-name-validator: 4.0.0 - optional: true - w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -26773,7 +25241,7 @@ snapshots: webpack-bundle-analyzer@4.10.2: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk: 8.3.4 commander: 7.2.0 debounce: 1.2.1 @@ -26863,11 +25331,11 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -26887,7 +25355,7 @@ snapshots: - esbuild - uglify-js - webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17)): + webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -26895,11 +25363,11 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -26911,7 +25379,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.8(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.17))) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))) watchpack: 2.5.1 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -26939,11 +25407,6 @@ snapshots: websocket-extensions@0.1.4: {} - whatwg-encoding@2.0.0: - dependencies: - iconv-lite: 0.6.3 - optional: true - whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -26954,12 +25417,6 @@ snapshots: whatwg-mimetype@4.0.0: optional: true - whatwg-url@11.0.0: - dependencies: - tr46: 3.0.0 - webidl-conversions: 7.0.0 - optional: true - whatwg-url@14.2.0: dependencies: tr46: 5.1.1 @@ -26981,9 +25438,9 @@ snapshots: dependencies: isexe: 2.0.0 - which@6.0.0: + which@6.0.1: dependencies: - isexe: 3.1.1 + isexe: 4.0.0 why-is-node-running@2.3.0: dependencies: @@ -27022,12 +25479,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.1.2 - wrappy@1.0.2: {} write-file-atomic@3.0.3: @@ -27053,9 +25504,6 @@ snapshots: dependencies: sax: 1.4.3 - xml-name-validator@4.0.0: - optional: true - xml-name-validator@5.0.0: optional: true diff --git a/server/.nvmrc b/server/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index be752dd862..f778c20afb 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -3,19 +3,19 @@ FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e0 ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ - COREPACK_HOME=/tmp + COREPACK_HOME=/tmp \ + PNPM_HOME=/buildcache/pnpm-store RUN npm install --global corepack@latest && \ corepack enable pnpm && \ + echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ - echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc + echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \ + echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \ + echo "fetch-retries=5" >> /usr/local/etc/npmrc && \ + mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \ + chmod -R o+rw /buildcache -COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/ -COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/ -COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/ -COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/ -WORKDIR /tmp/create-dep-cache -RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache WORKDIR /usr/src/app ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \ @@ -27,16 +27,14 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] FROM dev AS dev-container-server RUN apt-get update --allow-releaseinfo-change && \ - apt-get install sudo inetutils-ping openjdk-21-jre-headless \ + apt-get install inetutils-ping openjdk-21-jre-headless \ vim nano curl \ -y --no-install-recommends --fix-missing -RUN usermod -aG sudo node && \ - echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \ - mkdir -p /workspaces/immich +RUN mkdir -p /workspaces && \ + ln -s /usr/src/app /workspaces/immich -RUN chown node:node -R /workspaces -COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/ +COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/ WORKDIR /workspaces/immich diff --git a/server/package.json b/server/package.json index 31da11af3f..fa10f8bd1a 100644 --- a/server/package.json +++ b/server/package.json @@ -9,15 +9,15 @@ "build": "nest build", "format": "prettier --check .", "format:fix": "prettier --write .", - "start": "npm run start:dev", + "start": "pnpm run start:dev", "nest": "nest", "start:dev": "nest start --watch --", "start:debug": "nest start --debug 0.0.0.0:9230 --watch --", "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix", + "lint:fix": "pnpm run lint --fix", "check": "tsc --noEmit", - "check:code": "npm run format && npm run lint && npm run check", - "check:all": "npm run check:code && npm run test:cov", + "check:code": "pnpm run format && pnpm run lint && pnpm run check", + "check:all": "pnpm run check:code && pnpm run test:cov", "test": "vitest --config test/vitest.config.mjs", "test:cov": "vitest --config test/vitest.config.mjs --coverage", "test:medium": "vitest --config test/vitest.config.medium.mjs", @@ -28,7 +28,7 @@ "migrations:run": "node ./dist/bin/migrations.js run", "migrations:revert": "node ./dist/bin/migrations.js revert", "schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'", - "schema:reset": "npm run schema:drop && npm run migrations:run", + "schema:reset": "pnpm run schema:drop && pnpm run migrations:run", "sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:sql": "node ./dist/bin/sync-sql.js", "email:dev": "email dev -p 3050 --dir src/emails" @@ -45,14 +45,14 @@ "@nestjs/websockets": "^11.0.4", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", - "@opentelemetry/exporter-prometheus": "^0.210.0", - "@opentelemetry/instrumentation-http": "^0.210.0", - "@opentelemetry/instrumentation-ioredis": "^0.58.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.56.0", - "@opentelemetry/instrumentation-pg": "^0.62.0", + "@opentelemetry/exporter-prometheus": "^0.211.0", + "@opentelemetry/instrumentation-http": "^0.211.0", + "@opentelemetry/instrumentation-ioredis": "^0.59.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.57.0", + "@opentelemetry/instrumentation-pg": "^0.63.0", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1", - "@opentelemetry/sdk-node": "^0.210.0", + "@opentelemetry/sdk-node": "^0.211.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", @@ -69,7 +69,7 @@ "compression": "^1.8.0", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", - "cron": "4.3.5", + "cron": "4.4.0", "exiftool-vendored": "^34.3.0", "express": "^5.1.0", "fast-glob": "^3.3.2", @@ -135,7 +135,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", @@ -167,7 +167,7 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" }, "overrides": { "sharp": "^0.34.5" diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 2aef2e8c8b..2a2dd1857d 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -9,6 +9,7 @@ import { import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { SchemaCheck } from 'src/commands/schema-check'; import { VersionCommand } from 'src/commands/version.command'; export const commandsAndQuestions = [ @@ -28,4 +29,5 @@ export const commandsAndQuestions = [ ChangeMediaLocationCommand, PromptMediaLocationQuestions, PromptConfirmMoveQuestions, + SchemaCheck, ]; diff --git a/server/src/commands/schema-check.ts b/server/src/commands/schema-check.ts new file mode 100644 index 0000000000..c6e90fd9ca --- /dev/null +++ b/server/src/commands/schema-check.ts @@ -0,0 +1,60 @@ +import { Command, CommandRunner } from 'nest-commander'; +import { ErrorMessages } from 'src/constants'; +import { CliService } from 'src/services/cli.service'; +import { asHuman } from 'src/sql-tools/schema-diff'; + +@Command({ + name: 'schema-check', + description: 'Verify database migrations and check for schema drift', +}) +export class SchemaCheck extends CommandRunner { + constructor(private service: CliService) { + super(); + } + + async run(): Promise { + try { + const { migrations, drift } = await this.service.schemaReport(); + + if (migrations.every((item) => item.status === 'applied')) { + console.log('Migrations are up to date'); + } else { + console.log('Migration issues detected:'); + for (const migration of migrations) { + switch (migration.status) { + case 'deleted': { + console.log(` - ${migration.name} was applied, but the file no longer exists on disk`); + break; + } + + case 'missing': { + console.log(` - ${migration.name} exists, but has not been applied to the database`); + break; + } + } + } + } + + if (drift.items.length === 0) { + console.log('\nNo schema drift detected'); + } else { + console.log(`\n${ErrorMessages.SchemaDrift}`); + for (const item of drift.items) { + console.log(` - ${item.type}: ` + asHuman(item)); + } + + console.log(` + +The below SQL is automatically generated and may be helpful for resolving drift. ** Use at your own risk! ** + +\`\`\`sql +${drift.asSql().join('\n')} +\`\`\` +`); + } + } catch (error) { + console.error(error); + console.error('Unable to debug migrations'); + } + } +} diff --git a/server/src/constants.ts b/server/src/constants.ts index 0b26ca94b7..e7fe91709f 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -4,6 +4,13 @@ import { dirname, join } from 'node:path'; import { SemVer } from 'semver'; import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; +export const ErrorMessages = { + InconsistentMediaLocation: + 'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location', + SchemaDrift: `Detected schema drift. For more information, see https://docs.immich.app/errors#schema-drift`, + TypeOrmUpgrade: 'Invalid upgrade path. For more information, see https://docs.immich.app/errors/#typeorm-upgrade', +}; + export const POSTGRES_VERSION_RANGE = '>=14.0.0'; export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2'; export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 942d44f4c3..e45eeb23f3 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,9 +1,8 @@ import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; +import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { DownloadService } from 'src/services/download.service'; @@ -36,7 +35,7 @@ export class DownloadController { 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { + downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } } diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 8875127a25..1f91409e80 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -22,21 +22,39 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { SharedLinkCreateDto, SharedLinkEditDto, + SharedLinkLoginDto, SharedLinkPasswordDto, SharedLinkResponseDto, SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; import { ApiTag, ImmichCookie, Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoginDetails } from 'src/services/auth.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { respondWithCookie } from 'src/utils/response'; import { UUIDParamDto } from 'src/validation'; +const getAuthTokens = (cookies: Record | undefined) => { + return cookies?.[ImmichCookie.SharedLinkToken]?.split(',') || []; +}; + +const merge = (cookies: Record | undefined, token: string) => { + const authTokens = getAuthTokens(cookies); + if (!authTokens.includes(token)) { + authTokens.push(token); + } + + return authTokens.join(','); +}; + @ApiTags(ApiTag.SharedLinks) @Controller('shared-links') export class SharedLinkController { - constructor(private service: SharedLinkService) {} + constructor( + private service: SharedLinkService, + private logger: LoggingRepository, + ) {} @Get() @Authenticated({ permission: Permission.SharedLinkRead }) @@ -49,6 +67,28 @@ export class SharedLinkController { return this.service.getAll(auth, dto); } + @Post('login') + @Authenticated({ sharedLink: true }) + @Endpoint({ + summary: 'Shared link login', + description: 'Login to a password protected shared link', + history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'), + }) + async sharedLinkLogin( + @Auth() auth: AuthDto, + @Body() dto: SharedLinkLoginDto, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @GetLoginDetails() loginDetails: LoginDetails, + ): Promise { + const { sharedLink, token } = await this.service.login(auth, dto); + + return respondWithCookie(res, sharedLink, { + isSecure: loginDetails.isSecure, + values: [{ key: ImmichCookie.SharedLinkToken, value: merge(req.cookies, token) }], + }); + } + @Get('me') @Authenticated({ sharedLink: true }) @Endpoint({ @@ -59,19 +99,19 @@ export class SharedLinkController { async getMySharedLink( @Auth() auth: AuthDto, @Query() dto: SharedLinkPasswordDto, - @Req() request: Request, + @Req() req: Request, @Res({ passthrough: true }) res: Response, @GetLoginDetails() loginDetails: LoginDetails, ): Promise { - const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken]; - if (sharedLinkToken) { - dto.token = sharedLinkToken; + if (dto.password) { + this.logger.deprecate( + 'Passing shared link password via query parameters is deprecated and will be removed in the next major release. Please use POST /shared-links/login instead.', + ); + + return this.sharedLinkLogin(auth, { password: dto.password }, req, res, loginDetails); } - const body = await this.service.getMine(auth, dto); - return respondWithCookie(res, body, { - isSecure: loginDetails.isSecure, - values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [], - }); + + return this.service.getMine(auth, getAuthTokens(req.cookies)); } @Get(':id') diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts index e71ffdadd2..ff3b3f6acd 100644 --- a/server/src/dtos/asset-response.dto.spec.ts +++ b/server/src/dtos/asset-response.dto.spec.ts @@ -1,14 +1,14 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEditAction } from 'src/dtos/editing.dto'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { faceStub } from 'test/fixtures/face.stub'; -import { personStub } from 'test/fixtures/person.stub'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { PersonFactory } from 'test/factories/person.factory'; describe('mapAsset', () => { describe('peopleWithFaces', () => { it('should transform all faces when a person has multiple faces in the same image', () => { + const person = PersonFactory.create(); const face1 = { - ...faceStub.primaryFace1, boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, @@ -18,8 +18,6 @@ describe('mapAsset', () => { }; const face2 = { - ...faceStub.primaryFace1, - id: 'assetFaceId-second', boundingBoxX1: 300, boundingBoxY1: 400, boundingBoxX2: 400, @@ -28,16 +26,22 @@ describe('mapAsset', () => { imageHeight: 800, }; - const asset = { - ...assetStub.withCropEdit, - faces: [face1, face2], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - }; + const asset = AssetFactory.from() + .face(face1, (builder) => builder.person(person)) + .face(face2, (builder) => builder.person(person)) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .edit({ + action: AssetEditAction.Crop, + parameters: { + width: 1512, + height: 1152, + x: 216, + y: 1512, + }, + }) + .build(); - const result = mapAsset(asset as any); + const result = mapAsset(asset); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); @@ -61,32 +65,22 @@ describe('mapAsset', () => { }); it('should transform unassigned faces with edits and dimensions', () => { - const unassignedFace = { - ...faceStub.noPerson1, + const unassignedFace = AssetFaceFactory.create({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200, imageWidth: 1000, imageHeight: 800, - }; + }); - const asset = { - ...assetStub.withCropEdit, - faces: [unassignedFace], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - edits: [ - { - action: AssetEditAction.Crop, - parameters: { x: 50, y: 50, width: 500, height: 400 }, - }, - ], - }; + const asset = AssetFactory.from() + .face(unassignedFace) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } }) + .build(); - const result = mapAsset(asset as any); + const result = mapAsset(asset); expect(result.unassignedFaces).toBeDefined(); expect(result.unassignedFaces).toHaveLength(1); @@ -101,10 +95,6 @@ describe('mapAsset', () => { it('should handle multiple people each with multiple faces', () => { const person1Face1 = { - ...faceStub.primaryFace1, - id: 'face-1-1', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, @@ -114,10 +104,6 @@ describe('mapAsset', () => { }; const person1Face2 = { - ...faceStub.primaryFace1, - id: 'face-1-2', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 300, boundingBoxY1: 300, boundingBoxX2: 400, @@ -127,10 +113,6 @@ describe('mapAsset', () => { }; const person2Face1 = { - ...faceStub.mergeFace1, - id: 'face-2-1', - person: personStub.mergePerson, - personId: personStub.mergePerson.id, boundingBoxX1: 500, boundingBoxY1: 100, boundingBoxX2: 600, @@ -139,23 +121,22 @@ describe('mapAsset', () => { imageHeight: 800, }; - const asset = { - ...assetStub.withCropEdit, - faces: [person1Face1, person1Face2, person2Face1], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - edits: [], - }; + const person = PersonFactory.create({ id: 'person-1' }); - const result = mapAsset(asset as any); + const asset = AssetFactory.from() + .face(person1Face1, (builder) => builder.person(person)) + .face(person1Face2, (builder) => builder.person(person)) + .face(person2Face1, (builder) => builder.person({ id: 'person-2' })) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .build(); + + const result = mapAsset(asset); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(2); - const person1 = result.people!.find((p) => p.id === personStub.withName.id); - const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id); + const person1 = result.people!.find((p) => p.id === 'person-1'); + const person2 = result.people!.find((p) => p.id === 'person-2'); expect(person1).toBeDefined(); expect(person1!.faces).toHaveLength(2); @@ -173,10 +154,6 @@ describe('mapAsset', () => { it('should combine faces of the same person into a single entry', () => { const face1 = { - ...faceStub.primaryFace1, - id: 'face-1', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, @@ -186,10 +163,6 @@ describe('mapAsset', () => { }; const face2 = { - ...faceStub.primaryFace1, - id: 'face-2', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 300, boundingBoxY1: 300, boundingBoxX2: 400, @@ -198,24 +171,21 @@ describe('mapAsset', () => { imageHeight: 800, }; - const asset = { - ...assetStub.withCropEdit, - faces: [face1, face2], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - edits: [], - }; + const person = PersonFactory.create(); - const result = mapAsset(asset as any); + const asset = AssetFactory.from() + .face(face1, (builder) => builder.person(person)) + .face(face2, (builder) => builder.person(person)) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .build(); + + const result = mapAsset(asset); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); - const person = result.people![0]; - expect(person.id).toBe(personStub.withName.id); - expect(person.faces).toHaveLength(2); + expect(result.people![0].id).toBe(person.id); + expect(result.people![0].faces).toHaveLength(2); }); }); }); diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index 2f877e3c0b..ef52a72bd0 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsInt, IsPositive } from 'class-validator'; -import { Optional, ValidateUUID } from 'src/validation'; +import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DownloadInfoDto { @ValidateUUID({ each: true, optional: true, description: 'Asset IDs to download' }) @@ -32,3 +33,8 @@ export class DownloadArchiveInfo { @ApiProperty({ description: 'Asset IDs in this archive' }) assetIds!: string[]; } + +export class DownloadArchiveDto extends AssetIdsDto { + @ValidateBoolean({ optional: true, description: 'Download edited asset if available' }) + edited?: boolean; +} diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts index 8bb1eef47b..3c4c063b10 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -118,7 +118,15 @@ export class AssetEditActionListDto { Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits, ) @ApiProperty({ - anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })), + items: { + anyOf: Object.values(actionToClass).map((type) => ({ $ref: getSchemaPath(type) })), + discriminator: { + propertyName: 'action', + mapping: Object.fromEntries( + Object.entries(actionToClass).map(([action, type]) => [action, getSchemaPath(type)]), + ), + }, + }, description: 'List of edit actions to apply (crop, rotate, or mirror)', }) edits!: AssetEditActionItem[]; diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 1465f68953..b2ecc70a3a 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString } from 'class-validator'; import { SharedLink } from 'src/database'; -import { HistoryBuilder } from 'src/decorators'; +import { HistoryBuilder, Property } from 'src/decorators'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { SharedLinkType } from 'src/enum'; -import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; export class SharedLinkSearchDto { @ValidateUUID({ optional: true, description: 'Filter by album ID' }) @@ -94,6 +94,11 @@ export class SharedLinkEditDto { changeExpiryTime?: boolean; } +export class SharedLinkLoginDto { + @ValidateString({ description: 'Shared link password', example: 'password' }) + password!: string; +} + export class SharedLinkPasswordDto { @ApiPropertyOptional({ example: 'password', description: 'Link password' }) @IsString() @@ -112,7 +117,10 @@ export class SharedLinkResponseDto { description!: string | null; @ApiProperty({ description: 'Has password' }) password!: string | null; - @ApiPropertyOptional({ description: 'Access token' }) + @Property({ + description: 'Access token', + history: new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0'), + }) token?: string | null; @ApiProperty({ description: 'Owner user ID' }) userId!: string; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 0f3a458c35..4b8323cd59 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -587,6 +587,7 @@ where -- AssetRepository.getForOriginal select + "asset"."id", "originalFileName", "asset_file"."path" as "editedPath", "originalPath" @@ -596,7 +597,21 @@ from and "asset_file"."isEdited" = $1 and "asset_file"."type" = $2 where - "asset"."id" = $3 + "asset"."id" in ($3) + +-- AssetRepository.getForOriginals +select + "asset"."id", + "originalFileName", + "asset_file"."path" as "editedPath", + "originalPath" +from + "asset" + left join "asset_file" on "asset"."id" = "asset_file"."assetId" + and "asset_file"."isEdited" = $1 + and "asset_file"."type" = $2 +where + "asset"."id" in ($3) -- AssetRepository.getForThumbnail select @@ -621,3 +636,98 @@ from where "asset"."id" = $1 and "asset"."type" = $2 + +-- AssetRepository.getForOcr +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForEdit +select + "asset"."type", + "asset"."livePhotoVideoId", + "asset"."originalPath", + "asset"."originalFileName", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation", + "asset_exif"."projectionType" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForMetadataExtractionTags +select + "asset_exif"."tags" +from + "asset_exif" +where + "asset_exif"."assetId" = $1 + +-- AssetRepository.getForFaces +select + "asset_exif"."exifImageHeight", + "asset_exif"."exifImageWidth", + "asset_exif"."orientation", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForUpdateTags +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tag"."value" + from + "tag" + inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId" + where + "asset"."id" = "tag_asset"."assetId" + ) as agg + ) as "tags" +from + "asset" +where + "asset"."id" = $1 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 59f0f12424..964aaaccee 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -286,19 +286,6 @@ from -- PersonRepository.getFacesByIds select "asset_face".*, - ( - select - to_json(obj) - from - ( - select - "asset".* - from - "asset" - where - "asset"."id" = "asset_face"."assetId" - ) as obj - ) as "asset", ( select to_json(obj) @@ -355,3 +342,14 @@ from "person" where "id" in ($1) + +-- PersonRepository.getForFeatureFaceUpdate +select + "asset_face"."id" +from + "asset_face" + inner join "asset" on "asset"."id" = "asset_face"."assetId" + and "asset"."isOffline" = $1 +where + "asset_face"."assetId" = $2 + and "asset_face"."personId" = $3 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1a060c4715..e424d4e0b8 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; @@ -1008,12 +1009,12 @@ export class AssetRepository { return count; } - @GenerateSql({ params: [DummyValue.UUID, true] }) - async getForOriginal(id: string, isEdited: boolean) { + private buildGetForOriginal(ids: string[], isEdited: boolean) { return this.db .selectFrom('asset') + .select('asset.id') .select('originalFileName') - .where('asset.id', '=', id) + .where('asset.id', 'in', ids) .$if(isEdited, (qb) => qb .leftJoin('asset_file', (join) => @@ -1024,8 +1025,17 @@ export class AssetRepository { ) .select('asset_file.path as editedPath'), ) - .select('originalPath') - .executeTakeFirstOrThrow(); + .select('originalPath'); + } + + @GenerateSql({ params: [DummyValue.UUID, true] }) + getForOriginal(id: string, isEdited: boolean) { + return this.buildGetForOriginal([id], isEdited).executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [[DummyValue.UUID], true] }) + getForOriginals(ids: string[], isEdited: boolean) { + return this.buildGetForOriginal(ids, isEdited).execute(); } @GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview, true] }) @@ -1050,4 +1060,68 @@ export class AssetRepository { .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForOcr(id: string) { + return this.db + .selectFrom('asset') + .where('asset.id', '=', id) + .select(withEdits) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select(['asset_exif.exifImageWidth', 'asset_exif.exifImageHeight', 'asset_exif.orientation']) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForEdit(id: string) { + return this.db + .selectFrom('asset') + .select(['asset.type', 'asset.livePhotoVideoId', 'asset.originalPath', 'asset.originalFileName']) + .where('asset.id', '=', id) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select([ + 'asset_exif.exifImageWidth', + 'asset_exif.exifImageHeight', + 'asset_exif.orientation', + 'asset_exif.projectionType', + ]) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForMetadataExtractionTags(id: string) { + return this.db + .selectFrom('asset_exif') + .select('asset_exif.tags') + .where('asset_exif.assetId', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForFaces(id: string) { + return this.db + .selectFrom('asset') + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select(['asset_exif.exifImageHeight', 'asset_exif.exifImageWidth', 'asset_exif.orientation']) + .select(withEdits) + .where('asset.id', '=', id) + .executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForUpdateTags(id: string) { + return this.db + .selectFrom('asset') + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('tag') + .select('tag.value') + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .whereRef('asset.id', '=', 'tag_asset.assetId'), + ).as('tags'), + ) + .where('asset.id', '=', id) + .executeTakeFirstOrThrow(); + } } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 55ed2c1176..650820b18e 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -19,7 +19,9 @@ import { GenerateSql } from 'src/decorators'; import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode import { DB } from 'src/schema'; +import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { vectorIndexQuery } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; @@ -246,11 +248,11 @@ export class DatabaseRepository { } const dimSize = await this.getDimensionSize(table); lists ||= this.targetListCount(await this.getRowCount(table)); - await this.db.schema.dropIndex(indexName).ifExists().execute(); - if (table === 'smart_search') { - await this.db.schema.alterTable(table).dropConstraint('dim_size_constraint').ifExists().execute(); - } await this.db.transaction().execute(async (tx) => { + await sql`DROP INDEX IF EXISTS ${sql.raw(indexName)}`.execute(tx); + if (table === 'smart_search') { + await sql`ALTER TABLE ${sql.raw(table)} DROP CONSTRAINT IF EXISTS dim_size_constraint`.execute(tx); + } if (!rows.some((row) => row.columnName === 'embedding')) { this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`); await sql`TRUNCATE TABLE ${sql.raw(table)}`.execute(tx); @@ -281,6 +283,27 @@ export class DatabaseRepository { return rows[0].db; } + getMigrations() { + return this.db.selectFrom('kysely_migrations').select(['name', 'timestamp']).orderBy('name', 'asc').execute(); + } + + async getSchemaDrift() { + const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); + const target = await schemaFromDatabase(this.db, {}); + + const drift = schemaDiff(source, target, { + tables: { ignoreExtra: true }, + constraints: { ignoreExtra: false }, + indexes: { ignoreExtra: true }, + triggers: { ignoreExtra: true }, + columns: { ignoreExtra: true }, + functions: { ignoreExtra: false }, + parameters: { ignoreExtra: true }, + }); + + return drift; + } + async getDimensionSize(table: string, column = 'embedding'): Promise { const { rows } = await sql<{ dimsize: number }>` SELECT atttypmod as dimsize diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 1334d1220f..3c36bf62db 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -106,7 +106,7 @@ export class MetadataRepository { readTags(path: string): Promise { const args = mimeTypes.isVideo(path) ? ['-ee'] : []; - return this.exiftool.read(path, args).catch((error) => { + return this.exiftool.read(path, { readArgs: args }).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); return {}; }) as Promise; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 85e75483c5..00156a2492 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; @@ -485,12 +485,6 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select((eb) => - jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as( - 'asset', - ), - ) - .$narrowType<{ asset: NotNull }>() .select(withPerson) .where('asset_face.assetId', 'in', assetIds) .where('asset_face.personId', 'in', personIds) @@ -583,4 +577,15 @@ export class PersonRepository { } }); } + + @GenerateSql({ params: [{ personId: DummyValue.UUID, assetId: DummyValue.UUID }] }) + getForFeatureFaceUpdate({ personId, assetId }: { personId: string; assetId: string }) { + return this.db + .selectFrom('asset_face') + .select('asset_face.id') + .where('asset_face.assetId', '=', assetId) + .where('asset_face.personId', '=', personId) + .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) + .executeTakeFirst(); + } } diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index d9cdfb49d6..f9abcae097 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -170,6 +170,8 @@ export interface Migrations { } export interface DB { + kysely_migrations: { timestamp: string; name: string }; + activity: ActivityTable; album: AlbumTable; diff --git a/server/src/schema/migrations/1744910873969-InitialMigration.ts b/server/src/schema/migrations/1744910873969-InitialMigration.ts index b703a47536..530b084f83 100644 --- a/server/src/schema/migrations/1744910873969-InitialMigration.ts +++ b/server/src/schema/migrations/1744910873969-InitialMigration.ts @@ -1,4 +1,5 @@ import { Kysely, sql } from 'kysely'; +import { ErrorMessages } from 'src/constants'; import { DatabaseExtension } from 'src/enum'; import { getVectorExtension } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -16,9 +17,7 @@ export async function up(db: Kysely): Promise { rows: [lastMigration], } = await lastMigrationSql.execute(db); if (lastMigration?.name !== 'AddMissingIndex1744910873956') { - throw new Error( - 'Invalid upgrade path. For more information, see https://docs.immich.app/errors/#typeorm-upgrade', - ); + throw new Error(ErrorMessages.TypeOrmUpgrade); } logger.log('Database has up to date TypeORM migrations, skipping initial Kysely migration'); return; diff --git a/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts b/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts new file mode 100644 index 0000000000..f09257a3ce --- /dev/null +++ b/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX "asset_id_timeline_notDeleted_idx" ON "asset" ("id") WHERE visibility = 'timeline' AND "deletedAt" IS NULL;`.execute(db); + await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE "deletedAt" IS NULL AND "isVisible" IS TRUE;`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_id_timeline_notDeleted_idx', '{"type":"index","name":"asset_id_timeline_notDeleted_idx","sql":"CREATE INDEX \\"asset_id_timeline_notDeleted_idx\\" ON \\"asset\\" (\\"id\\") WHERE visibility = ''timeline'' AND \\"deletedAt\\" IS NULL;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE \\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE;"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "asset_id_timeline_notDeleted_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_id_timeline_notDeleted_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db); +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 8b156f2a17..8a3b3ac611 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -27,6 +27,11 @@ import { }) // schemaFromDatabase does not preserve column order @Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) +@Index({ + name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', + columns: ['personId', 'assetId'], + where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', +}) @Index({ columns: ['personId', 'assetId'] }) export class AssetFaceTable { @PrimaryGeneratedColumn() diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 956eb5ebda..ffa6a7547f 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -55,6 +55,11 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; using: 'gin', expression: 'f_unaccent("originalFileName") gin_trgm_ops', }) +@Index({ + name: 'asset_id_timeline_notDeleted_idx', + columns: ['id'], + where: `visibility = 'timeline' AND "deletedAt" IS NULL`, +}) // For all assets, each originalpath must be unique per user and library export class AssetTable { @PrimaryGeneratedColumn() diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 0bcb87e2f4..5fb45690cf 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -9,13 +9,16 @@ import { AssetFile } from 'src/database'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { MapAsset } from 'src/dtos/asset-response.dto'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { UploadBody } from 'src/types'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; import { ImmichFileResponse } from 'src/utils/file'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFileFactory } from 'test/factories/asset-file.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -107,6 +110,7 @@ const validVideos = [ '.mp4', '.mpg', '.mts', + '.mxf', '.vob', '.webm', '.wmv', @@ -426,56 +430,52 @@ describe(AssetMediaService.name, () => { }); it('should handle a live photo', async () => { - mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }) + .owner(authStub.user1.user) + .build(); + const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); + mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.create.mockResolvedValueOnce(asset); await expect( - sut.uploadAsset( - authStub.user1, - { ...createDto, livePhotoVideoId: 'live-photo-motion-asset' }, - fileStub.livePhotoStill, - ), + sut.uploadAsset(authStub.user1, { ...createDto, livePhotoVideoId: motionAsset.id }, fileStub.livePhotoStill), ).resolves.toEqual({ status: AssetMediaStatus.CREATED, - id: 'live-photo-still-asset', + id: asset.id, }); - expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.getById).toHaveBeenCalledWith(motionAsset.id); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should hide the linked motion asset', async () => { - mocks.asset.getById.mockResolvedValueOnce({ - ...assetStub.livePhotoMotionAsset, - visibility: AssetVisibility.Timeline, - }); - mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build(); + const asset = AssetFactory.create(); + mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.create.mockResolvedValueOnce(asset); await expect( - sut.uploadAsset( - authStub.user1, - { ...createDto, livePhotoVideoId: 'live-photo-motion-asset' }, - fileStub.livePhotoStill, - ), + sut.uploadAsset(authStub.user1, { ...createDto, livePhotoVideoId: motionAsset.id }, fileStub.livePhotoStill), ).resolves.toEqual({ status: AssetMediaStatus.CREATED, - id: 'live-photo-still-asset', + id: asset.id, }); - expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.getById).toHaveBeenCalledWith(motionAsset.id); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: 'live-photo-motion-asset', + id: motionAsset.id, visibility: AssetVisibility.Hidden, }); }); it('should handle a sidecar file', async () => { - mocks.asset.getById.mockResolvedValueOnce(assetStub.image); - mocks.asset.create.mockResolvedValueOnce(assetStub.image); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); + mocks.asset.getById.mockResolvedValueOnce(asset); + mocks.asset.create.mockResolvedValueOnce(asset); await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ status: AssetMediaStatus.CREATED, - id: assetStub.image.id, + id: asset.id, }); expect(mocks.storage.utimes).toHaveBeenCalledWith( @@ -501,13 +501,14 @@ describe(AssetMediaService.name, () => { }); it('should download a file', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getForOriginal.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForOriginal.mockResolvedValue(asset); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).resolves.toEqual( + await expect(sut.downloadOriginal(authStub.admin, asset.id, {})).resolves.toEqual( new ImmichFileResponse({ - path: '/original/path.jpg', - fileName: 'asset-id.jpg', + path: asset.originalPath, + fileName: asset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -515,28 +516,19 @@ describe(AssetMediaService.name, () => { }); it('should download edited file by default when edits exist', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getForOriginal.mockResolvedValue({ - ...editedAsset, - editedPath: '/uploads/user-id/fullsize/edited.jpg', - }); + const editedAsset = AssetFactory.from() + .edit() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .file({ type: AssetFileType.FullSize, isEdited: true }) + .build(); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id])); + mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path }); + + await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, {})).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/fullsize/edited.jpg', - fileName: 'asset-id.jpg', + path: editedAsset.files[3].path, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -544,28 +536,19 @@ describe(AssetMediaService.name, () => { }); it('should download edited file when edited=true', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getForOriginal.mockResolvedValue({ - ...editedAsset, - editedPath: '/uploads/user-id/fullsize/edited.jpg', - }); + const editedAsset = AssetFactory.from() + .edit() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .file({ type: AssetFileType.FullSize, isEdited: true }) + .build(); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id])); + mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path }); + + await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: true })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/fullsize/edited.jpg', - fileName: 'asset-id.jpg', + path: editedAsset.files[3].path, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -573,28 +556,18 @@ describe(AssetMediaService.name, () => { }); it('should not return the unedited version if requested using a shared link', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForOriginal.mockResolvedValue({ - ...editedAsset, - editedPath: '/uploads/user-id/fullsize/edited.jpg', - }); + const fullsizeEdited = AssetFileFactory.create({ type: AssetFileType.FullSize, isEdited: true }); + const editedAsset = AssetFactory.from().edit({ action: AssetEditAction.Crop }).file(fullsizeEdited).build(); - await expect(sut.downloadOriginal(authStub.adminSharedLink, 'asset-id', { edited: false })).resolves.toEqual( + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id])); + mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: fullsizeEdited.path }); + + await expect( + sut.downloadOriginal(AuthFactory.from().sharedLink().build(), editedAsset.id, { edited: false }), + ).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/fullsize/edited.jpg', - fileName: 'asset-id.jpg', + path: fullsizeEdited.path, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -602,25 +575,19 @@ describe(AssetMediaService.name, () => { }); it('should download original file when edited=false', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + const editedAsset = AssetFactory.from() + .edit() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .file({ type: AssetFileType.FullSize, isEdited: true }) + .build(); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id])); mocks.asset.getForOriginal.mockResolvedValue(editedAsset); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual( + await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: false })).resolves.toEqual( new ImmichFileResponse({ - path: '/original/path.jpg', - fileName: 'asset-id.jpg', + path: editedAsset.originalPath, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -638,129 +605,118 @@ describe(AssetMediaService.name, () => { }); it('should fall back to preview if the requested thumbnail file does not exist', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/path/to/preview.jpg' }); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), - ).resolves.toEqual( + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual( new ImmichFileResponse({ - path: '/path/to/preview.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); }); it('should get preview file', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/thumbs/path.jpg' }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), - ).resolves.toEqual( + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.PREVIEW })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/path.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_preview.jpg', + fileName: `IMG_${asset.id}_preview.jpg`, }), ); }); it('should get thumbnail file', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/webp/path.ext' }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), - ).resolves.toEqual( + const asset = AssetFactory.from() + .file({ type: AssetFileType.Thumbnail, path: '/uploads/user-id/webp/path.ext' }) + .build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/webp/path.ext', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'application/octet-stream', - fileName: 'asset-id_thumbnail.ext', + fileName: `IMG_${asset.id}_thumbnail.ext`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, false); }); it('should get original thumbnail by default', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', - }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), - ).resolves.toEqual( + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, false); }); it('should get edited thumbnail when edited=true', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', - }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail, isEdited: true }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }), + sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: true }), ).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, true); }); it('should get original thumbnail when edited=false', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', - }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: false }), + sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: false }), ).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, false); }); it('should not return the unedited version if requested using a shared link', async () => { - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', - }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); await expect( - sut.viewThumbnail(authStub.adminSharedLink, assetStub.image.id, { + sut.viewThumbnail(authStub.adminSharedLink, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: true, }), ).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, true); }); }); @@ -774,18 +730,20 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the video asset could not be found', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); + await expect(sut.playbackVideo(authStub.admin, asset.id)).rejects.toBeInstanceOf(NotFoundException); }); it('should return the encoded video path if available', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); - mocks.asset.getForVideo.mockResolvedValue(assetStub.hasEncodedVideo); + const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForVideo.mockResolvedValue(asset); - await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( + await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: assetStub.hasEncodedVideo.encodedVideoPath!, + path: asset.encodedVideoPath!, cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), @@ -793,12 +751,13 @@ describe(AssetMediaService.name, () => { }); it('should fall back to the original path', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); - mocks.asset.getForVideo.mockResolvedValue(assetStub.video); + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForVideo.mockResolvedValue(asset); - await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( + await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: assetStub.video.originalPath, + path: asset.originalPath, cacheControl: CacheControl.PrivateWithCache, contentType: 'application/octet-stream', }), diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index ff4dfa96ff..db895f8321 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,16 +1,14 @@ 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 { AssetEditAction } from 'src/dtos/editing.dto'; -import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; +import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const stats: AssetStats = { @@ -34,14 +32,8 @@ describe(AssetService.name, () => { expect(sut).toBeDefined(); }); - const mockGetById = (assets: MapAsset[]) => { - mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); - }; - beforeEach(() => { ({ sut, mocks } = newTestService(AssetService)); - - mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); describe('getStatistics', () => { @@ -79,7 +71,7 @@ describe(AssetService.name, () => { describe('getRandom', () => { it('should get own random assets', async () => { mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); await sut.getRandom(authStub.admin, 1); @@ -90,7 +82,7 @@ describe(AssetService.name, () => { const partner = factory.partner({ inTimeline: false }); const auth = factory.auth({ user: { id: partner.sharedWithId } }); - mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); mocks.partner.getAll.mockResolvedValue([partner]); await sut.getRandom(auth, 1); @@ -102,7 +94,7 @@ describe(AssetService.name, () => { const partner = factory.partner({ inTimeline: true }); const auth = factory.auth({ user: { id: partner.sharedWithId } }); - mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); mocks.partner.getAll.mockResolvedValue([partner]); await sut.getRandom(auth, 1); @@ -113,88 +105,90 @@ describe(AssetService.name, () => { describe('get', () => { it('should allow owner access', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.admin, assetStub.image.id); + await sut.get(authStub.admin, asset.id); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set([assetStub.image.id]), + new Set([asset.id]), undefined, ); }); it('should allow shared link access', async () => { - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.adminSharedLink, assetStub.image.id); + await sut.get(authStub.adminSharedLink, asset.id); expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, - new Set([assetStub.image.id]), + new Set([asset.id]), ); }); it('should strip metadata for shared link if exif is disabled', async () => { - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.from().exif({ description: 'foo' }).build(); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, - assetStub.image.id, + asset.id, ); expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); expect(result).not.toHaveProperty('exifInfo'); expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, - new Set([assetStub.image.id]), + new Set([asset.id]), ); }); it('should allow partner sharing access', async () => { - mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.admin, assetStub.image.id); + await sut.get(authStub.admin, asset.id); - expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set([assetStub.image.id]), - ); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([asset.id])); }); it('should allow shared album access', async () => { - mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.admin, assetStub.image.id); + await sut.get(authStub.admin, asset.id); - expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set([assetStub.image.id]), - ); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([asset.id])); }); it('should throw an error for no access', async () => { - await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(authStub.admin, AssetFactory.create().id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error for an invalid shared link', async () => { - await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(authStub.adminSharedLink, AssetFactory.create().id)).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error if the asset could not be found', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(authStub.admin, asset.id)).rejects.toBeInstanceOf(BadRequestException); }); }); @@ -208,38 +202,41 @@ describe(AssetService.name, () => { }); it('should update the asset', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getById.mockResolvedValue(assetStub.image); - mocks.asset.update.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.update.mockResolvedValue(asset); - await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); + await sut.update(authStub.admin, asset.id, { isFavorite: true }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, isFavorite: true }); }); it('should update the exif description', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getById.mockResolvedValue(assetStub.image); - mocks.asset.update.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.update.mockResolvedValue(asset); - await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); + await sut.update(authStub.admin, asset.id, { description: 'Test description' }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] }, + { assetId: asset.id, description: 'Test description', lockedProperties: ['description'] }, { lockedPropertiesBehavior: 'append' }, ); }); it('should update the exif rating', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getById.mockResolvedValueOnce(assetStub.image); - mocks.asset.update.mockResolvedValueOnce(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValueOnce(asset); + mocks.asset.update.mockResolvedValueOnce(asset); - await sut.update(authStub.admin, 'asset-1', { rating: 3 }); + await sut.update(authStub.admin, asset.id, { rating: 3 }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( { - assetId: 'asset-1', + assetId: asset.id, rating: 3, lockedProperties: ['rating'], }, @@ -249,74 +246,79 @@ describe(AssetService.name, () => { it('should fail linking a live video if the motion part could not be found', async () => { const auth = AuthFactory.create(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); await expect( - sut.update(auth, assetStub.livePhotoStillAsset.id, { - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + sut.update(auth, asset.id, { + livePhotoVideoId: 'unknown', }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.update).not.toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + id: asset.id, + livePhotoVideoId: 'unknown', }); expect(mocks.asset.update).not.toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, + id: 'unknown', visibility: AssetVisibility.Timeline, }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { - assetId: assetStub.livePhotoMotionAsset.id, + assetId: 'unknown', userId: auth.user.id, }); }); it('should fail linking a live video if the motion part is not a video', async () => { const auth = AuthFactory.create(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + const motionAsset = AssetFactory.from().owner(auth.user).build(); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); await expect( - sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + sut.update(authStub.admin, asset.id, { + livePhotoVideoId: motionAsset.id, }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.update).not.toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + id: asset.id, + livePhotoVideoId: motionAsset.id, }); expect(mocks.asset.update).not.toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, + id: motionAsset.id, visibility: AssetVisibility.Timeline, }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { - assetId: assetStub.livePhotoMotionAsset.id, + assetId: motionAsset.id, userId: auth.user.id, }); }); it('should fail linking a live video if the motion part has a different owner', async () => { const auth = AuthFactory.create(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + const motionAsset = AssetFactory.create({ type: AssetType.Video }); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(motionAsset); await expect( - sut.update(auth, assetStub.livePhotoStillAsset.id, { - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + sut.update(auth, asset.id, { + livePhotoVideoId: motionAsset.id, }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.update).not.toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + id: asset.id, + livePhotoVideoId: motionAsset.id, }); expect(mocks.asset.update).not.toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, + id: motionAsset.id, visibility: AssetVisibility.Timeline, }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { - assetId: assetStub.livePhotoMotionAsset.id, + assetId: motionAsset.id, userId: auth.user.id, }); }); @@ -346,35 +348,40 @@ describe(AssetService.name, () => { it('should unlink a live video', async () => { const auth = AuthFactory.create(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); - mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - mocks.asset.update.mockResolvedValueOnce(assetStub.image); + const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }) + .owner(auth.user) + .build(); + const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); + const unlinkedAsset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValueOnce(asset); + mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.update.mockResolvedValueOnce(unlinkedAsset); - await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); + await sut.update(auth, asset.id, { livePhotoVideoId: null }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, + id: asset.id, livePhotoVideoId: null, }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, - visibility: assetStub.livePhotoStillAsset.visibility, + id: motionAsset.id, + visibility: asset.visibility, }); expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', { - assetId: assetStub.livePhotoMotionAsset.id, + assetId: motionAsset.id, userId: auth.user.id, }); }); it('should fail unlinking a live video if the asset could not be found', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - // eslint-disable-next-line unicorn/no-useless-undefined - mocks.asset.getById.mockResolvedValueOnce(undefined); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValueOnce(void 0); - await expect( - sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), - ).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(authStub.admin, asset.id, { livePhotoVideoId: null })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); @@ -555,7 +562,11 @@ describe(AssetService.name, () => { describe('handleAssetDeletion', () => { it('should clean up files', async () => { - const asset = assetStub.image; + const asset = AssetFactory.from() + .file({ type: AssetFileType.Thumbnail }) + .file({ type: AssetFileType.Preview }) + .file({ type: AssetFileType.FullSize }) + .build(); mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -565,12 +576,7 @@ describe(AssetService.name, () => { { name: JobName.FileDelete, data: { - files: [ - '/uploads/user-id/webp/path.ext', - '/uploads/user-id/thumbs/path.jpg', - '/uploads/user-id/fullsize/path.webp', - asset.originalPath, - ], + files: [...asset.files.map(({ path }) => path), asset.originalPath], }, }, ], @@ -579,91 +585,60 @@ 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 () => { + const asset = AssetFactory.from() + .stack({}, (builder) => builder.asset()) + .build(); mocks.stack.delete.mockResolvedValue(); mocks.assetJob.getForAssetDeletion.mockResolvedValue({ - ...assetStub.primaryImage, - stack: { - id: 'stack-id', - primaryAssetId: assetStub.primaryImage.id, - assets: [{ id: 'one-asset' }], - }, + ...asset, + // TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually + stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) }, }); - await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); - expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId); }); it('should delete a live photo', async () => { - mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any); + const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build(); + const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); mocks.asset.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ - id: assetStub.livePhotoStillAsset.id, + id: asset.id, deleteOnDisk: true, }); expect(mocks.job.queue.mock.calls).toEqual([ - [ - { - name: JobName.AssetDelete, - data: { - id: assetStub.livePhotoMotionAsset.id, - deleteOnDisk: true, - }, - }, - ], - [ - { - name: JobName.FileDelete, - data: { - files: [ - '/uploads/user-id/webp/path.ext', - '/uploads/user-id/thumbs/path.jpg', - '/uploads/user-id/fullsize/path.webp', - 'fake_path/asset_1.jpeg', - ], - }, - }, - ], + [{ name: JobName.AssetDelete, data: { id: motionAsset.id, deleteOnDisk: true } }], + [{ name: JobName.FileDelete, data: { files: [asset.originalPath] } }], ]); }); it('should not delete a live motion part if it is being used by another asset', async () => { + const asset = AssetFactory.create({ livePhotoVideoId: newUuid() }); mocks.asset.getLivePhotoCount.mockResolvedValue(2); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); - await sut.handleAssetDeletion({ - id: assetStub.livePhotoStillAsset.id, - deleteOnDisk: true, - }); + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); expect(mocks.job.queue.mock.calls).toEqual([ - [ - { - name: JobName.FileDelete, - data: { - files: [ - '/uploads/user-id/webp/path.ext', - '/uploads/user-id/thumbs/path.jpg', - '/uploads/user-id/fullsize/path.webp', - 'fake_path/asset_1.jpeg', - ], - }, - }, - ], + [{ name: JobName.FileDelete, data: { files: [`/data/library/IMG_${asset.id}.jpg`] } }], ]); }); it('should update usage', async () => { - mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.image); - await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); + const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build(); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000); }); it('should fail if asset could not be found', async () => { mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0); - await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( + await expect(sut.handleAssetDeletion({ id: AssetFactory.create().id, deleteOnDisk: true })).resolves.toBe( JobStatus.Failed, ); }); @@ -681,28 +656,30 @@ describe(AssetService.name, () => { it('should return OCR data for an asset', async () => { const ocr1 = factory.assetOcr({ text: 'Hello World' }); const ocr2 = factory.assetOcr({ text: 'Test Image' }); + const asset = AssetFactory.from().exif().build(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); - await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([ocr1, ocr2]); + await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([ocr1, ocr2]); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set(['asset-1']), + new Set([asset.id]), undefined, ); - expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1'); + expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); }); it('should return empty array when no OCR data exists', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + const asset = AssetFactory.from().exif().build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([]); - mocks.asset.getById.mockResolvedValue(assetStub.image); - await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([]); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); + await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([]); - expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1'); + expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); }); }); @@ -746,7 +723,7 @@ describe(AssetService.name, () => { describe('getUserAssetsByDeviceId', () => { it('get assets by device id', async () => { - const assets = [assetStub.image, assetStub.image1]; + const assets = [AssetFactory.create(), AssetFactory.create()]; mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ed427684f1..f6098248ed 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -404,15 +404,19 @@ export class AssetService extends BaseService { async getOcr(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const ocr = await this.ocrRepository.getByAssetId(id); - const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true }); + const asset = await this.assetRepository.getForOcr(id); - if (!asset || !asset.exifInfo || !asset.edits) { + if (!asset) { throw new BadRequestException('Asset not found'); } - const dimensions = getDimensions(asset.exifInfo); + const dimensions = getDimensions({ + exifImageHeight: asset.exifImageHeight, + exifImageWidth: asset.exifImageWidth, + orientation: asset.orientation, + }); - return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions)); + return ocr.map((item) => transformOcrBoundingBox(item, asset.edits, dimensions)); } async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { @@ -551,7 +555,7 @@ export class AssetService extends BaseService { async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] }); - const asset = await this.assetRepository.getById(id, { exifInfo: true }); + const asset = await this.assetRepository.getForEdit(id); if (!asset) { throw new BadRequestException('Asset not found'); } @@ -576,15 +580,21 @@ export class AssetService extends BaseService { throw new BadRequestException('Editing SVG images is not supported'); } + // check that crop parameters will not go out of bounds + const { width: assetWidth, height: assetHeight } = getDimensions(asset); + + if (!assetWidth || !assetHeight) { + throw new BadRequestException('Asset dimensions are not available for editing'); + } + const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop); if (cropIndex > 0) { throw new BadRequestException('Crop action must be the first edit action'); } - const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop); if (crop) { // check that crop parameters will not go out of bounds - const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!); + const { width: assetWidth, height: assetHeight } = getDimensions(asset); if (!assetWidth || !assetHeight) { throw new BadRequestException('Asset dimensions are not available for editing'); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index ce62f98aa1..479fd130a6 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,15 +1,59 @@ import { Injectable } from '@nestjs/common'; -import { isAbsolute } from 'node:path'; +import { isAbsolute, join } from 'node:path'; import { SALT_ROUNDS } from 'src/constants'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { schemaDiff } from 'src/sql-tools'; import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; +export type SchemaReport = { + migrations: MigrationStatus[]; + drift: ReturnType; +}; + +type MigrationStatus = { + name: string; + status: 'applied' | 'missing' | 'deleted'; +}; + @Injectable() export class CliService extends BaseService { + async schemaReport(): Promise { + // eslint-disable-next-line unicorn/prefer-module + const allFiles = await this.storageRepository.readdir(join(__dirname, '../schema/migrations')); + const files = allFiles.filter((file) => file.endsWith('.js')).map((file) => file.slice(0, -3)); + const rows = await this.databaseRepository.getMigrations(); + const filesSet = new Set(files); + const rowsSet = new Set(rows.map((item) => item.name)); + const combined = [...filesSet, ...rowsSet].toSorted(); + + const migrations: MigrationStatus[] = []; + + for (const name of combined) { + if (filesSet.has(name) && rowsSet.has(name)) { + migrations.push({ name, status: 'applied' }); + continue; + } + + if (filesSet.has(name) && !rowsSet.has(name)) { + migrations.push({ name, status: 'missing' }); + continue; + } + + if (!filesSet.has(name) && rowsSet.has(name)) { + migrations.push({ name, status: 'deleted' }); + continue; + } + } + + const drift = await this.databaseRepository.getSchemaDrift(); + + return { migrations, drift }; + } + async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); return users.map((user) => mapUserAdmin(user)); diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index 9ca37200b7..429e60aede 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -554,7 +554,7 @@ describe(DatabaseBackupService.name, () => { "bin": "/usr/lib/postgresql/14/bin/psql", "databaseMajorVersion": 14, "databasePassword": "", - "databaseUsername": "", + "databaseUsername": "postgres", "databaseVersion": "14.10 (Debian 14.10-1.pgdg120+1)", } `); diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index de7090fa83..3c964c950c 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -139,7 +139,8 @@ export class DatabaseBackupService { // remove known bad parameters parsedUrl.searchParams.delete('uselibpqcompat'); - databaseUsername = parsedUrl.username; + databaseUsername = parsedUrl.username || parsedUrl.searchParams.get('user'); + url = parsedUrl.toString(); } diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index e30722d3d7..bae3a705a4 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -21,6 +21,11 @@ describe(DatabaseService.name, () => { extensionRange = '0.2.x'; mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.VectorChord); mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange); + mocks.database.getSchemaDrift.mockResolvedValue({ + items: [], + asSql: () => [], + asHuman: () => [], + }); versionBelowRange = '0.1.0'; minVersionInRange = '0.2.0'; diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index 2ff0e0ca27..1b2289e6e3 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import semver from 'semver'; -import { EXTENSION_NAMES, VECTOR_EXTENSIONS } from 'src/constants'; +import { ErrorMessages, EXTENSION_NAMES, VECTOR_EXTENSIONS } from 'src/constants'; import { OnEvent } from 'src/decorators'; import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @@ -124,6 +124,17 @@ export class DatabaseService extends BaseService { const { database } = this.configRepository.getEnv(); if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); + + this.logger.log('Checking for schema drift'); + const drift = await this.databaseRepository.getSchemaDrift(); + if (drift.items.length === 0) { + this.logger.log('No schema drift detected'); + } else { + this.logger.warn(`${ErrorMessages.SchemaDrift} or run \`immich-admin schema-check\``); + for (const warning of drift.asHuman()) { + this.logger.warn(` - ${warning}`); + } + } } await Promise.all([ this.databaseRepository.prewarm(VectorIndex.Clip), diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 7721b12ffc..1ae1b0b4d8 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -3,7 +3,6 @@ import { Readable } from 'node:stream'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadService } from 'src/services/download.service'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { vitest } from 'vitest'; @@ -37,21 +36,18 @@ describe(DownloadService.name, () => { finalize: vitest.fn(), stream: new Readable(), }; + const asset = AssetFactory.create(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id, 'unknown-asset'])); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id, 'unknown-asset'] })).resolves.toEqual({ stream: archiveMock.stream, }); expect(archiveMock.addFile).toHaveBeenCalledTimes(1); - expect(archiveMock.addFile).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('/data/library/IMG_123.jpg'), - 'IMG_123.jpg', - ); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset.originalPath, asset.originalFileName); }); it('should log a warning if the original path could not be resolved', async () => { @@ -66,7 +62,7 @@ describe(DownloadService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -90,7 +86,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -108,15 +104,14 @@ describe(DownloadService.name, () => { finalize: vitest.fn(), stream: new Readable(), }; + const asset1 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); + const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1' }, - { ...assetStub.noResizePath, id: 'asset-2' }, - ]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); @@ -131,15 +126,14 @@ describe(DownloadService.name, () => { finalize: vitest.fn(), stream: new Readable(), }; + const asset1 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); + const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-2' }, - { ...assetStub.noResizePath, id: 'asset-1' }, - ]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); @@ -155,18 +149,17 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, - ]); + const asset = AssetFactory.create({ originalPath: '/path/to/symlink.jpg' }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id] })).resolves.toEqual({ stream: archiveMock.stream, }); - expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', asset.originalFileName); }); }); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index a5f734e59c..8d939e9635 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,9 +1,8 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; 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 { DownloadArchiveDto, DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { Permission } from 'src/enum'; import { ImmichReadStream } from 'src/repositories/storage.repository'; import { BaseService } from 'src/services/base.service'; @@ -80,11 +79,11 @@ export class DownloadService extends BaseService { return { totalSize, archives }; } - async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { + async downloadArchive(auth: AuthDto, dto: DownloadArchiveDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); - const assets = await this.assetRepository.getByIds(dto.assetIds); + const assets = await this.assetRepository.getForOriginals(dto.assetIds, dto.edited ?? false); const assetMap = new Map(assets.map((asset) => [asset.id, asset])); const paths: Record = {}; @@ -94,7 +93,7 @@ export class DownloadService extends BaseService { continue; } - const { originalPath, originalFileName } = asset; + const { originalPath, editedPath, originalFileName } = asset; let filename = originalFileName; const count = paths[filename] || 0; @@ -104,9 +103,10 @@ export class DownloadService extends BaseService { filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`; } - let realpath = originalPath; + let realpath = dto.edited && editedPath ? editedPath : originalPath; + try { - realpath = await this.storageRepository.realpath(originalPath); + realpath = await this.storageRepository.realpath(realpath); } catch { this.logger.warn('Unable to resolve realpath', { originalPath }); } diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index e5ac9f82ba..0b216e8b8a 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,8 +1,9 @@ import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; @@ -38,19 +39,17 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { + const asset = AssetFactory.create(); mocks.duplicateRepository.getAll.mockResolvedValue([ { duplicateId: 'duplicate-id', - assets: [assetStub.image, assetStub.image], + assets: [asset, asset], }, ]); await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ { duplicateId: 'duplicate-id', - assets: [ - expect.objectContaining({ id: assetStub.image.id }), - expect.objectContaining({ id: assetStub.image.id }), - ], + assets: [expect.objectContaining({ id: asset.id }), expect.objectContaining({ id: asset.id })], }, ]); }); @@ -101,7 +100,8 @@ describe(SearchService.name, () => { }); it('should queue missing assets', async () => { - mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([asset])); await sut.handleQueueSearchDuplicates({}); @@ -109,13 +109,14 @@ describe(SearchService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectDuplicates, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should queue all assets', async () => { - mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([asset])); await sut.handleQueueSearchDuplicates({ force: true }); @@ -123,7 +124,7 @@ describe(SearchService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectDuplicates, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); @@ -150,9 +151,7 @@ describe(SearchService.name, () => { }, }, }); - const id = assetStub.livePhotoMotionAsset.id; - - const result = await sut.handleSearchDuplicates({ id }); + const result = await sut.handleSearchDuplicates({ id: newUuid() }); expect(result).toBe(JobStatus.Skipped); expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled(); @@ -167,9 +166,7 @@ describe(SearchService.name, () => { }, }, }); - const id = assetStub.livePhotoMotionAsset.id; - - const result = await sut.handleSearchDuplicates({ id }); + const result = await sut.handleSearchDuplicates({ id: newUuid() }); expect(result).toBe(JobStatus.Skipped); expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled(); @@ -178,51 +175,49 @@ describe(SearchService.name, () => { it('should fail if asset is not found', async () => { mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(void 0); - const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + const asset = AssetFactory.create(); + const result = await sut.handleSearchDuplicates({ id: asset.id }); expect(result).toBe(JobStatus.Failed); - expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); + expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${asset.id} not found`); }); it('should skip if asset is part of stack', async () => { - const id = assetStub.primaryImage.id; - mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' }); + const asset = AssetFactory.from().stack().build(); + mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: asset.stackId }); - const result = await sut.handleSearchDuplicates({ id }); + const result = await sut.handleSearchDuplicates({ id: asset.id }); expect(result).toBe(JobStatus.Skipped); - expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is part of a stack, skipping`); }); it('should skip if asset is not visible', async () => { - const id = assetStub.livePhotoMotionAsset.id; - mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ - ...hasEmbedding, - visibility: AssetVisibility.Hidden, - }); + const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden }); + mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, ...asset }); - const result = await sut.handleSearchDuplicates({ id }); + const result = await sut.handleSearchDuplicates({ id: asset.id }); expect(result).toBe(JobStatus.Skipped); - expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is not visible, skipping`); }); it('should fail if asset is missing embedding', async () => { mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null }); - const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + const asset = AssetFactory.create(); + const result = await sut.handleSearchDuplicates({ id: asset.id }); expect(result).toBe(JobStatus.Failed); - expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is missing embedding`); }); it('should search for duplicates and update asset with duplicateId', async () => { mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasEmbedding); - mocks.duplicateRepository.search.mockResolvedValue([ - { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, - ]); + const asset = AssetFactory.create(); + mocks.duplicateRepository.search.mockResolvedValue([{ assetId: asset.id, distance: 0.01, duplicateId: null }]); mocks.duplicateRepository.merge.mockResolvedValue(); - const expectedAssetIds = [assetStub.image.id, hasEmbedding.id]; + const expectedAssetIds = [asset.id, hasEmbedding.id]; const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index c23b4f05df..a464c9e174 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,7 +1,8 @@ -import { ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; +import { AssetType, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { JobService } from 'src/services/job.service'; import { JobItem } from 'src/types'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(JobService.name, () => { @@ -55,22 +56,22 @@ describe(JobService.name, () => { { item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, jobs: [], - stub: [assetStub.image], + stub: [AssetFactory.create({ id: 'asset-1' })], }, { item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, jobs: [], - stub: [assetStub.video], + stub: [AssetFactory.create({ id: 'asset-1', type: AssetType.Video })], }, { item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr], - stub: [assetStub.livePhotoStillAsset], + stub: [AssetFactory.create({ id: 'asset-1', livePhotoVideoId: newUuid() })], }, { item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr, JobName.AssetEncodeVideo], - stub: [assetStub.video], + stub: [AssetFactory.create({ id: 'asset-1', type: AssetType.Video })], }, { item: { name: JobName.SmartSearch, data: { id: 'asset-1' } }, diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index dbff1ca467..d0c2d0a785 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -6,11 +6,11 @@ import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; -import { factory, newUuid } from 'test/small.factory'; +import { factory, newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { vitest } from 'vitest'; @@ -306,13 +306,13 @@ describe(LibraryService.name, () => { it('should queue asset sync', async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); + const asset = AssetFactory.create({ libraryId: library.id, isExternal: true }); mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(async function* generator() {}); - mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external])); + mocks.library.streamAssetIds.mockReturnValue(makeStream([asset])); mocks.asset.getLibraryAssetCount.mockResolvedValue(1); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n }); - mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external])); const response = await sut.handleQueueSyncAssets({ id: library.id }); @@ -322,7 +322,7 @@ describe(LibraryService.name, () => { libraryId: library.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns, - assetIds: [assetStub.external.id], + assetIds: [asset.id], progressCounter: 1, totalAssets: 1, }, @@ -343,8 +343,9 @@ describe(LibraryService.name, () => { describe('handleSyncAssets', () => { it('should offline assets no longer on disk', async () => { + const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.external.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], @@ -352,20 +353,21 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); - expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset.id], { isOffline: true, deletedAt: expect.anything(), }); }); it('should set assets deleted from disk as offline', async () => { + const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.external.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/data/user2'], exclusionPatterns: [], @@ -373,20 +375,21 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); - expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset.id], { isOffline: true, deletedAt: expect.anything(), }); }); it('should do nothing with offline assets deleted from disk', async () => { + const asset = AssetFactory.create({ isOffline: true, deletedAt: newDate() }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.trashedOffline.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/data/user2'], exclusionPatterns: [], @@ -394,7 +397,7 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); @@ -403,8 +406,9 @@ describe(LibraryService.name, () => { }); it('should un-trash an asset previously marked as offline', async () => { + const asset = AssetFactory.create({ originalPath: '/original/path.jpg', isOffline: true, deletedAt: newDate() }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.trashedOffline.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/original/'], exclusionPatterns: [], @@ -412,20 +416,21 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); - mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); + mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); - expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset.id], { isOffline: false, deletedAt: null, }); }); it('should do nothing with offline asset if covered by exclusion pattern', async () => { + const asset = AssetFactory.create({ originalPath: '/original/path.jpg', isOffline: true, deletedAt: newDate() }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.trashedOffline.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/original/'], exclusionPatterns: ['**/path.jpg'], @@ -433,8 +438,8 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); - mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); + mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); @@ -444,8 +449,9 @@ describe(LibraryService.name, () => { }); it('should do nothing with offline asset if not in import path', async () => { + const asset = AssetFactory.create({ originalPath: '/original/path.jpg', isOffline: true, deletedAt: newDate() }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.trashedOffline.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/import/'], exclusionPatterns: [], @@ -453,8 +459,8 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); - mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); + mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); @@ -464,8 +470,9 @@ describe(LibraryService.name, () => { }); it('should do nothing with unchanged online assets', async () => { + const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.external.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], @@ -473,8 +480,8 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); - mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); + mocks.storage.stat.mockResolvedValue({ mtime: asset.fileModifiedAt } as Stats); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); @@ -482,8 +489,9 @@ describe(LibraryService.name, () => { }); it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => { + const asset = AssetFactory.create({ isOffline: true, deletedAt: newDate() }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.trashedOffline.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], @@ -491,13 +499,13 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); - mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); + mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).toHaveBeenCalledWith( - [assetStub.trashedOffline.id], + [asset.id], expect.not.objectContaining({ fileCreatedAt: expect.anything(), }), @@ -505,8 +513,9 @@ describe(LibraryService.name, () => { }); it('should update with online assets that have changed', async () => { + const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true }); const mockAssetJob: ILibraryBulkIdsJob = { - assetIds: [assetStub.external.id], + assetIds: [asset.id], libraryId: newUuid(), importPaths: ['/'], exclusionPatterns: [], @@ -514,13 +523,9 @@ describe(LibraryService.name, () => { progressCounter: 0, }; - if (assetStub.external.fileModifiedAt == null) { - throw new Error('fileModifiedAt is null'); - } + const mtime = new Date(asset.fileModifiedAt.getDate() + 1); - const mtime = new Date(assetStub.external.fileModifiedAt.getDate() + 1); - - mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); + mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]); mocks.storage.stat.mockResolvedValue({ mtime } as Stats); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); @@ -529,7 +534,7 @@ describe(LibraryService.name, () => { { name: JobName.SidecarCheck, data: { - id: assetStub.external.id, + id: asset.id, source: 'upload', }, }, @@ -548,13 +553,14 @@ describe(LibraryService.name, () => { it('should import a new asset', async () => { const library = factory.library(); + const asset = AssetFactory.create(); const mockLibraryJob: ILibraryFileJob = { libraryId: library.id, paths: ['/data/user1/photo.jpg'], }; - mocks.asset.createAll.mockResolvedValue([assetStub.image]); + mocks.asset.createAll.mockResolvedValue([asset]); mocks.library.get.mockResolvedValue(library); await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.Success); @@ -575,7 +581,7 @@ describe(LibraryService.name, () => { { name: JobName.SidecarCheck, data: { - id: assetStub.image.id, + id: asset.id, source: 'upload', }, }, @@ -602,7 +608,7 @@ describe(LibraryService.name, () => { it('should delete a library', async () => { const library = factory.library(); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.library.get.mockResolvedValue(library); await sut.delete(library.id); @@ -614,7 +620,7 @@ describe(LibraryService.name, () => { it('should allow an external library to be deleted', async () => { const library = factory.library(); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.library.get.mockResolvedValue(library); await sut.delete(library.id); @@ -630,7 +636,7 @@ describe(LibraryService.name, () => { it('should unwatch an external library when deleted', async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); @@ -962,7 +968,7 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); @@ -981,7 +987,7 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), ); @@ -999,12 +1005,13 @@ describe(LibraryService.name, () => { it('should handle a file unlink event', async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); + const asset = AssetFactory.create(); mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(asset); mocks.storage.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: assetStub.image.originalPath }] }), + makeMockWatcher({ items: [{ event: 'unlink', value: asset.originalPath }] }), ); await sut.watchAll(); @@ -1013,16 +1020,17 @@ describe(LibraryService.name, () => { name: JobName.LibraryRemoveAsset, data: { libraryId: library.id, - paths: [assetStub.image.originalPath], + paths: [asset.originalPath], }, }); }); it('should handle an error event', async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); + const asset = AssetFactory.create({ libraryId: library.id, isExternal: true }); mocks.library.get.mockResolvedValue(library); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(asset); mocks.library.getAll.mockResolvedValue([library]); mocks.storage.watch.mockImplementation( makeMockWatcher({ @@ -1115,7 +1123,7 @@ describe(LibraryService.name, () => { const library = factory.library(); mocks.library.get.mockResolvedValue(library); - mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.image1])); + mocks.library.streamAssetIds.mockReturnValue(makeStream([AssetFactory.create()])); await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); }); diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index e7369569d2..d58ae67140 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,7 +1,7 @@ import { MapService } from 'src/services/map.service'; import { AlbumFactory } from 'test/factories/album.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { authStub } from 'test/fixtures/auth.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -16,36 +16,41 @@ describe(MapService.name, () => { describe('getMapMarkers', () => { it('should get geo information of assets', async () => { - const asset = assetStub.withLocation; + const auth = AuthFactory.create(); + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); 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, + lat: asset.exifInfo.latitude!, + lon: asset.exifInfo.longitude!, + city: asset.exifInfo.city, + state: asset.exifInfo.state, + country: asset.exifInfo.country, }; mocks.partner.getAll.mockResolvedValue([]); mocks.map.getMapMarkers.mockResolvedValue([marker]); - const markers = await sut.getMapMarkers(authStub.user1, {}); + const markers = await sut.getMapMarkers(auth, {}); expect(markers).toHaveLength(1); expect(markers[0]).toEqual(marker); }); it('should include partner assets', async () => { - const partner = factory.partner(); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const auth = AuthFactory.create(); + const partner = factory.partner({ sharedWithId: auth.user.id }); - const asset = assetStub.withLocation; + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); 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, + lat: asset.exifInfo.latitude!, + lon: asset.exifInfo.longitude!, + city: asset.exifInfo.city, + state: asset.exifInfo.state, + country: asset.exifInfo.country, }; mocks.partner.getAll.mockResolvedValue([partner]); mocks.map.getMapMarkers.mockResolvedValue([marker]); @@ -62,21 +67,24 @@ describe(MapService.name, () => { }); it('should include assets from shared albums', async () => { - const asset = assetStub.withLocation; + const auth = AuthFactory.create(userStub.user1); + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); 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, + lat: asset.exifInfo.latitude!, + lon: asset.exifInfo.longitude!, + city: asset.exifInfo.city, + state: asset.exifInfo.state, + country: asset.exifInfo.country, }; mocks.partner.getAll.mockResolvedValue([]); mocks.map.getMapMarkers.mockResolvedValue([marker]); mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]); mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]); - const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); + const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true }); expect(markers).toHaveLength(1); expect(markers[0]).toEqual(marker); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 823383c29d..399eb5d6a0 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,10 +1,13 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetPathType, + AssetStatus, AssetType, + AssetVisibility, AudioCodec, Colorspace, ExifOrientation, @@ -18,9 +21,8 @@ import { } from 'src/enum'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub, previewFile } from 'test/fixtures/asset.stub'; -import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; @@ -43,9 +45,12 @@ describe(MediaService.name, () => { expect(sut).toBeDefined(); }); + // TODO these should all become medium tests of either the service or the repository. + // The entire logic of what to queue lives in the SQL query now describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); @@ -55,7 +60,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); @@ -69,7 +74,8 @@ describe(MediaService.name, () => { }); it('should queue trashed assets when force is true', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived])); + const asset = AssetFactory.create({ status: AssetStatus.Trashed, deletedAt: new Date() }); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -78,13 +84,14 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.trashed.id }, + data: { id: asset.id }, }, ]); }); it('should queue archived assets when force is true', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived])); + const asset = AssetFactory.create({ visibility: AssetVisibility.Archive }); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -93,15 +100,15 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.archived.id }, + data: { id: asset.id }, }, ]); }); it('should queue all people with missing thumbnail path', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image])); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); - mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); + mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -120,7 +127,8 @@ describe(MediaService.name, () => { }); it('should queue all assets with missing resize path', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noResizePath])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -128,7 +136,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); @@ -196,7 +204,8 @@ describe(MediaService.name, () => { }); it('should queue assets with edits but missing edited thumbnails', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + const asset = AssetFactory.from().edit().build(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -204,7 +213,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetEditThumbnailGeneration, - data: { id: assetStub.withCropEdit.id }, + data: { id: asset.id }, }, ]); @@ -212,8 +221,9 @@ describe(MediaService.name, () => { }); it('should not queue assets with missing edited fullsize when feature is disabled', async () => { + const asset = AssetFactory.from().edit().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -242,7 +252,8 @@ describe(MediaService.name, () => { }); it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + const asset = AssetFactory.from().edit().build(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -250,11 +261,11 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.withCropEdit.id }, + data: { id: asset.id }, }, { name: JobName.AssetEditThumbnailGeneration, - data: { id: assetStub.withCropEdit.id }, + data: { id: asset.id }, }, ]); @@ -264,16 +275,15 @@ describe(MediaService.name, () => { describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { - mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success); expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.AssetFileMigration, data: { id: assetStub.image.id } }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonFileMigration, data: { id: personStub.withName.id } }, ]); @@ -283,39 +293,42 @@ describe(MediaService.name, () => { describe('handleAssetMigration', () => { it('should fail if asset does not exist', async () => { mocks.assetJob.getForMigrationJob.mockResolvedValue(void 0); - await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed); + await expect(sut.handleAssetMigration({ id: 'non-existent' })).resolves.toBe(JobStatus.Failed); expect(mocks.move.getByEntity).not.toHaveBeenCalled(); }); it('should move asset files', async () => { - mocks.assetJob.getForMigrationJob.mockResolvedValue(assetStub.image); + const asset = AssetFactory.from() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .build(); + mocks.assetJob.getForMigrationJob.mockResolvedValue(asset); mocks.move.create.mockResolvedValue({ - entityId: assetStub.image.id, + entityId: asset.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); + await expect(sut.handleAssetMigration({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetFileType.FullSize, - oldPath: '/uploads/user-id/fullsize/path.webp', - newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_fullsize.jpeg'), + oldPath: asset.files[0].path, + newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_fullsize.jpeg`, }); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetFileType.Preview, - oldPath: '/uploads/user-id/thumbs/path.jpg', - newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_preview.jpeg'), + oldPath: asset.files[1].path, + newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`, }); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetFileType.Thumbnail, - oldPath: '/uploads/user-id/webp/path.ext', - newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_thumbnail.webp'), + oldPath: asset.files[2].path, + newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_thumbnail.webp`, }); expect(mocks.move.create).toHaveBeenCalledTimes(3); }); @@ -339,66 +352,71 @@ describe(MediaService.name, () => { it('should skip thumbnail generation if asset not found', async () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(void 0); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: 'non-existent' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip thumbnail generation if asset type is unknown', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.image, type: 'foo' as AssetType }); + const asset = AssetFactory.create({ type: 'foo' as AssetType }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.Skipped); + await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.media.probe).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip video thumbnail generation if no video stream', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); - await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + await expect(sut.handleGenerateThumbnails({ id: asset.id })).rejects.toThrowError(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.livePhotoMotionAsset); + const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.Skipped); + expect(await sut.handleGenerateThumbnails({ id: asset.id })).toEqual(JobStatus.Skipped); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should delete previous preview if different path', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, data: { - files: expect.arrayContaining([previewFile.path]), + files: expect.arrayContaining([asset.files[0].path]), }, }); }); it('should generate P3 thumbnails for a wide gamut image', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.image, - exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif, - }); + const asset = AssetFactory.from() + .exif({ profileDescription: 'Adobe RGB', bitsPerSample: 14 }) + .files([AssetFileType.Preview, AssetFileType.Thumbnail]) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -444,27 +462,28 @@ describe(MediaService.name, () => { expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Preview, path: expect.any(String), isEdited: false, isProgressive: false, }, { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Thumbnail, path: expect.any(String), isEdited: false, isProgressive: false, }, ]); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); - await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -484,14 +503,14 @@ describe(MediaService.name, () => { ); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Preview, path: expect.any(String), isEdited: false, isProgressive: false, }, { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Thumbnail, path: expect.any(String), isEdited: false, @@ -501,9 +520,10 @@ describe(MediaService.name, () => { }); it('should tonemap thumbnail for hdr video', async () => { + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); - await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -523,14 +543,14 @@ describe(MediaService.name, () => { ); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Preview, path: expect.any(String), isEdited: false, isProgressive: false, }, { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Thumbnail, path: expect.any(String), isEdited: false, @@ -540,12 +560,13 @@ describe(MediaService.name, () => { }); it('should always generate video thumbnail in one pass', async () => { + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); - await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -565,9 +586,10 @@ describe(MediaService.name, () => { }); it('should not skip intra frames for MTS file', async () => { + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); - await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -582,9 +604,10 @@ describe(MediaService.name, () => { }); it('should override reserved color metadata', async () => { + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamReserved); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); - await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -601,10 +624,11 @@ describe(MediaService.name, () => { }); it('should use scaling divisible by 2 even when using quick sync', async () => { + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); - await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -618,18 +642,19 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - const previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`; - const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`; + const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.${format}`; + const thumbnailPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_thumbnail.webp`; - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -667,18 +692,19 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_preview.jpeg`); - const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`); + const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`; + const thumbnailPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_thumbnail.${format}`; - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -716,12 +742,13 @@ describe(MediaService.name, () => { }); it('should generate progressive JPEG for preview when enabled', async () => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: false } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, @@ -752,12 +779,13 @@ describe(MediaService.name, () => { }); it('should generate progressive JPEG for thumbnail when enabled', async () => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, @@ -788,13 +816,14 @@ describe(MediaService.name, () => { }); it('should never set isProgressive for videos', async () => { + const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect.objectContaining({ @@ -809,26 +838,30 @@ describe(MediaService.name, () => { }); it('should delete previous thumbnail if different path', async () => { + const asset = AssetFactory.from().exif().file({ type: AssetFileType.Preview }).build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, data: { - files: expect.arrayContaining([previewFile.path]), + files: expect.arrayContaining([asset.files[0].path]), }, }); }); it('should extract embedded image if enabled and available', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { @@ -839,14 +872,17 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image is too small', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -854,13 +890,16 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image not found', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -868,14 +907,17 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image extraction is not enabled', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -884,14 +926,17 @@ describe(MediaService.name, () => { it('should process invalid images if enabled', async () => { vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, + asset.originalPath, expect.objectContaining({ processInvalidImages: true }), ); @@ -917,14 +962,18 @@ describe(MediaService.name, () => { }); it('should extract full-size JPEG preview from RAW', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { @@ -951,14 +1000,18 @@ describe(MediaService.name, () => { }); it('should convert full-size WEBP preview from JXL preview of RAW', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { @@ -997,15 +1050,19 @@ describe(MediaService.name, () => { }); it('should generate full-size preview directly from RAW images when extractEmbedded is false', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1079,15 +1136,16 @@ describe(MediaService.name, () => { }); it('should skip generating full-size preview for web-friendly images', async () => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -1116,7 +1174,7 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { @@ -1161,7 +1219,7 @@ describe(MediaService.name, () => { .build(); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { @@ -1231,22 +1289,31 @@ describe(MediaService.name, () => { }); it('should skip videos', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); + const asset = AssetFactory.from({ type: AssetType.Video }).exif().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await expect(sut.handleAssetEditThumbnailGeneration({ id: assetStub.video.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should upsert 3 edited files for edit jobs', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from() + .exif() + .edit({ action: AssetEditAction.Crop }) + .files([ + { type: AssetFileType.FullSize, isEdited: true }, + { type: AssetFileType.Preview, isEdited: true }, + { type: AssetFileType.Thumbnail, isEdited: true }, + ]) + .build(); + + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( expect.arrayContaining([ @@ -1258,21 +1325,23 @@ describe(MediaService.name, () => { }); it('should apply edits when generating thumbnails', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from() + .exif() + .edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ edits: [ - { + expect.objectContaining({ action: 'crop', parameters: { height: 1152, width: 1512, x: 216, y: 1512 }, - }, + }), ], }), expect.any(String), @@ -1305,13 +1374,12 @@ describe(MediaService.name, () => { }); it('should generate all 3 edited files if an asset has edits', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from().exif().edit().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( @@ -1336,21 +1404,20 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' }); expect(mocks.media.generateThumbhash).toHaveBeenCalled(); }); it('should apply thumbhash if job source is edit and edits exist', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from().exif().edit().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = factory.buffer(); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer })); }); @@ -1439,7 +1506,7 @@ describe(MediaService.name, () => { expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.previewPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), { colorspace: Colorspace.P3, orientation: undefined, processInvalidImages: false, @@ -1753,7 +1820,8 @@ describe(MediaService.name, () => { describe('handleQueueVideoConversion', () => { it('should queue all video assets', async () => { - mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([assetStub.video])); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); @@ -1762,13 +1830,14 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetEncodeVideo, - data: { id: assetStub.video.id }, + data: { id: asset.id }, }, ]); }); it('should queue all video assets without encoded videos', async () => { - mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([assetStub.video])); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([asset])); await sut.handleQueueVideoConversion({}); @@ -1776,7 +1845,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetEncodeVideo, - data: { id: assetStub.video.id }, + data: { id: asset.id }, }, ]); }); @@ -1784,13 +1853,14 @@ describe(MediaService.name, () => { describe('handleVideoConversion', () => { beforeEach(() => { - mocks.assetJob.getForVideoConversion.mockResolvedValue(assetStub.video); + const asset = AssetFactory.create({ id: 'video-id', type: AssetType.Video, originalPath: '/original/path.ext' }); + mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); sut.videoInterfaces = { dri: ['renderD128'], mali: true }; }); it('should skip transcoding if asset not found', async () => { mocks.assetJob.getForVideoConversion.mockResolvedValue(void 0); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.probe).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1799,7 +1869,7 @@ describe(MediaService.name, () => { mocks.logger.isLevelEnabled.mockReturnValue(false); mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -1819,7 +1889,7 @@ describe(MediaService.name, () => { mocks.logger.isLevelEnabled.mockReturnValue(false); mocks.media.probe.mockResolvedValue(probeStub.multipleAudioStreams); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -1837,13 +1907,13 @@ describe(MediaService.name, () => { it('should skip a video without any streams', async () => { mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip a video without any height', async () => { mocks.media.probe.mockResolvedValue(probeStub.noHeight); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1851,7 +1921,7 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); + await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1862,14 +1932,14 @@ describe(MediaService.name, () => { }); mocks.media.transcode.mockRejectedValue(new Error('Error transcoding video')); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.Failed); + await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed); expect(mocks.media.transcode).toHaveBeenCalledTimes(1); }); it('should transcode when set to all', async () => { mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1884,7 +1954,7 @@ describe(MediaService.name, () => { it('should transcode when optimal and too big', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1899,7 +1969,7 @@ describe(MediaService.name, () => { it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1914,7 +1984,7 @@ describe(MediaService.name, () => { it('should transcode when max bitrate is not a number', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1931,7 +2001,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1946,7 +2016,7 @@ describe(MediaService.name, () => { it('should scale horizontally when video is horizontal', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1961,7 +2031,7 @@ describe(MediaService.name, () => { it('should scale vertically when video is vertical', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1978,7 +2048,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -1995,7 +2065,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2012,7 +2082,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Hevc, acceptedAudioCodecs: [AudioCodec.Aac] }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2033,7 +2103,7 @@ describe(MediaService.name, () => { acceptedAudioCodecs: [AudioCodec.Aac], }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2054,7 +2124,7 @@ describe(MediaService.name, () => { acceptedAudioCodecs: [AudioCodec.Aac], }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2069,7 +2139,7 @@ describe(MediaService.name, () => { it('should copy audio stream when audio matches target', async () => { mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2083,7 +2153,7 @@ describe(MediaService.name, () => { it('should remux when input is not an accepted container', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamAvi); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2099,33 +2169,33 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); + await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if transcoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not remux when input is not an accepted container and transcoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if target codec is invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = assetStub.hasEncodedVideo; + const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' }); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); @@ -2142,7 +2212,7 @@ describe(MediaService.name, () => { it('should set max bitrate if above 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2157,7 +2227,7 @@ describe(MediaService.name, () => { it('should default max bitrate to kbps if no unit is provided', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2172,7 +2242,7 @@ describe(MediaService.name, () => { it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2187,7 +2257,7 @@ describe(MediaService.name, () => { it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2208,7 +2278,7 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.Vp9, }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2229,7 +2299,7 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.Vp9, }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2244,7 +2314,7 @@ describe(MediaService.name, () => { it('should configure preset for vp9', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Vp9, preset: 'slow' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2259,7 +2329,7 @@ describe(MediaService.name, () => { it('should not configure preset for vp9 if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.Vp9 } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2274,7 +2344,7 @@ describe(MediaService.name, () => { it('should configure threads if above 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Vp9, threads: 2 } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2289,7 +2359,7 @@ describe(MediaService.name, () => { it('should disable thread pooling for h264 if thread limit is 1', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2304,7 +2374,7 @@ describe(MediaService.name, () => { it('should omit thread flags for h264 if thread limit is at or below 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2319,7 +2389,7 @@ describe(MediaService.name, () => { it('should disable thread pooling for hevc if thread limit is 1', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.Hevc } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2334,7 +2404,7 @@ describe(MediaService.name, () => { it('should omit thread flags for hevc if thread limit is at or below 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.Hevc } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2349,7 +2419,7 @@ describe(MediaService.name, () => { it('should use av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1 } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2374,7 +2444,7 @@ describe(MediaService.name, () => { it('should map `veryslow` preset to 4 for av1', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, preset: 'veryslow' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2389,7 +2459,7 @@ describe(MediaService.name, () => { it('should set max bitrate for av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, maxBitrate: '2M' } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2404,7 +2474,7 @@ describe(MediaService.name, () => { it('should set threads for av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, threads: 4 } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2421,7 +2491,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, threads: 4, maxBitrate: '2M' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2442,7 +2512,7 @@ describe(MediaService.name, () => { targetResolution: '1080p', }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -2451,21 +2521,21 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 }, }); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); + await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should fail if hwaccel option is invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); + await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for nvenc', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2502,7 +2572,7 @@ describe(MediaService.name, () => { twoPass: true, }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2519,7 +2589,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, maxBitrate: '10000k' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2536,7 +2606,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, maxBitrate: '10000k' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2553,7 +2623,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, preset: 'invalid' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2568,7 +2638,7 @@ describe(MediaService.name, () => { it('should ignore two pass for nvenc if max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2585,7 +2655,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2607,7 +2677,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2628,7 +2698,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2645,7 +2715,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, maxBitrate: '10000k' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2685,7 +2755,7 @@ describe(MediaService.name, () => { preferredHwDevice: '/dev/dri/renderD128', }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2705,7 +2775,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, preset: 'invalid' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2725,7 +2795,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, targetVideoCodec: VideoCodec.Vp9 }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2745,7 +2815,7 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); + await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -2754,7 +2824,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2775,7 +2845,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2801,7 +2871,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2830,7 +2900,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2848,7 +2918,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2869,7 +2939,7 @@ describe(MediaService.name, () => { it('should set options for vaapi', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2901,7 +2971,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, maxBitrate: '10000k' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2925,7 +2995,7 @@ describe(MediaService.name, () => { it('should set cq options for vaapi when max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2951,7 +3021,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, preset: 'invalid' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2970,7 +3040,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -2991,7 +3061,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, preferredHwDevice: '/dev/dri/renderD128' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3012,7 +3082,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -3037,7 +3107,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -3060,7 +3130,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -3080,7 +3150,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3098,7 +3168,7 @@ describe(MediaService.name, () => { ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledTimes(2); expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', @@ -3121,7 +3191,7 @@ describe(MediaService.name, () => { }); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledTimes(3); expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', @@ -3138,7 +3208,7 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledTimes(2); expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', @@ -3155,7 +3225,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: [], mali: true }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); + await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -3164,7 +3234,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3204,7 +3274,7 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.Hevc, }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3221,7 +3291,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3238,7 +3308,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3260,7 +3330,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3279,7 +3349,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: false, crf: 30, maxBitrate: '0' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3301,7 +3371,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3320,7 +3390,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is required and video is hdr', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3339,7 +3409,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is optimal and video is hdr', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3358,7 +3428,7 @@ describe(MediaService.name, () => { it('should transcode when policy is required and video is not yuv420p', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3373,7 +3443,7 @@ describe(MediaService.name, () => { it('should convert to yuv420p when scaling without tone-mapping', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream4K10Bit); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', expect.any(String), @@ -3389,10 +3459,10 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.logger.isLevelEnabled.mockReturnValue(true); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); - expect(mocks.media.transcode).toHaveBeenCalledWith(assetStub.video.originalPath, expect.any(String), { + expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: true }); + expect(mocks.media.transcode).toHaveBeenCalledWith('/original/path.ext', expect.any(String), { inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, @@ -3406,19 +3476,23 @@ describe(MediaService.name, () => { it('should not count frames for progress when log level is not debug', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.logger.isLevelEnabled.mockReturnValue(false); - await sut.handleVideoConversion({ id: assetStub.video.id }); + await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); }); it('should process unknown audio stream', async () => { + const asset = AssetFactory.create({ + type: AssetType.Video, + originalPath: '/original/path.ext', + }); mocks.media.probe.mockResolvedValue(probeStub.audioStreamUnknown); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); + mocks.asset.getByIds.mockResolvedValue([asset]); + await sut.handleVideoConversion({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( - '/original/path.ext', - '/data/encoded-video/user-id/as/se/asset-id.mp4', + asset.originalPath, + expect.stringContaining('video-id.mp4'), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:a copy']), diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index d94de020e0..1080407922 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -3,7 +3,6 @@ import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { defaults } from 'src/config'; -import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetFileType, AssetType, @@ -17,8 +16,6 @@ import { import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; @@ -125,27 +122,29 @@ describe(MetadataService.name, () => { describe('handleQueueMetadataExtraction', () => { it('should queue metadata extraction for all assets without exif values', async () => { - mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([asset])); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetExtractMetadata, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should queue metadata extraction for all assets', async () => { - mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([asset])); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetExtractMetadata, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); @@ -166,9 +165,9 @@ describe(MetadataService.name, () => { it('should handle an asset that could not be found', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: 'non-existent' }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith('non-existent'); expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -287,8 +286,8 @@ describe(MetadataService.name, () => { } as Stats); mockReadTags({ ISO: [160] }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), { lockedPropertiesBehavior: 'skip', }); @@ -296,7 +295,7 @@ describe(MetadataService.name, () => { id: asset.id, duration: null, fileCreatedAt: asset.fileCreatedAt, - fileModifiedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, localDateTime: asset.fileCreatedAt, width: null, height: null, @@ -383,11 +382,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from TagsList', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -397,16 +394,14 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from TagsList', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { userId: asset.ownerId, @@ -421,11 +416,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a string', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -435,11 +428,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -449,11 +440,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list with a number', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent', '2024'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -464,11 +453,9 @@ describe(MetadataService.name, () => { }); it('should extract hierarchal tags from Keywords', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -486,11 +473,9 @@ describe(MetadataService.name, () => { }); it('should ignore Keywords when TagsList is present', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child', 'Child'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Child'] }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -509,11 +494,9 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from HierarchicalSubject', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child', 'TagA'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'TagA'] }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -538,76 +521,72 @@ describe(MetadataService.name, () => { }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent', '2024'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) }); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ - userId: 'user-id', + userId: asset.ownerId, value: 'Mom|Dad', parent: undefined, }); }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }), - }); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should remove existing tags', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({}); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith('asset-id', []); + expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith(asset.id, []); }); it('should not apply motion photos if asset is video', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoMotionAsset, - visibility: AssetVisibility.Timeline, - }); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); @@ -617,23 +596,25 @@ describe(MetadataService.name, () => { }); it('should handle an invalid Directory Item', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); }); it('should extract the correct video orientation', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); - await sut.handleMetadataExtraction({ id: assetStub.video.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), { lockedPropertiesBehavior: 'skip' }, @@ -641,16 +622,14 @@ describe(MetadataService.name, () => { }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoWithOriginalFileName, - livePhotoVideoId: null, - libraryId: null, - }); + const asset = AssetFactory.create(); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, - mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); mockReadTags({ Directory: 'foo/bar/', @@ -662,57 +641,52 @@ describe(MetadataService.name, () => { EmbeddedVideoType: 'MotionPhoto_Data', }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.asset.create.mockResolvedValue(motionAsset); + mocks.crypto.randomUUID.mockReturnValue(motionAsset.id); const video = randomBytes(512); mocks.metadata.extractBinaryTag.mockResolvedValue(video); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( - assetStub.livePhotoWithOriginalFileName.originalPath, - 'MotionPhotoVideo', - ); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith(asset.originalPath, 'MotionPhotoVideo'); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', - fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, - fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, - id: fileStub.livePhotoMotion.uuid, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + id: motionAsset.id, visibility: AssetVisibility.Hidden, - libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, - localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, - originalFileName: 'asset_1.mp4', - originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), - ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, + libraryId: asset.libraryId, + localDateTime: asset.fileCreatedAt, + originalFileName: `IMG_${asset.id}.mp4`, + originalPath: expect.stringContaining(`${motionAsset.id}-MP.mp4`), + ownerId: asset.ownerId, type: AssetType.Video, }); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(motionAsset.originalPath, video); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoWithOriginalFileName.id, - livePhotoVideoId: fileStub.livePhotoMotion.uuid, + id: asset.id, + livePhotoVideoId: motionAsset.id, }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ name: JobName.AssetEncodeVideo, - data: { id: assetStub.livePhotoMotionAsset.id }, + data: { id: motionAsset.id }, }); }); it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { + const asset = AssetFactory.create(); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, - mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoWithOriginalFileName, - livePhotoVideoId: null, - libraryId: null, - }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), @@ -720,56 +694,51 @@ describe(MetadataService.name, () => { MotionPhoto: 1, }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.asset.create.mockResolvedValue(motionAsset); + mocks.crypto.randomUUID.mockReturnValue(motionAsset.id); const video = randomBytes(512); mocks.metadata.extractBinaryTag.mockResolvedValue(video); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( - assetStub.livePhotoWithOriginalFileName.originalPath, - 'EmbeddedVideoFile', - ); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith(asset.originalPath, 'EmbeddedVideoFile'); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', - fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, - fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, - id: fileStub.livePhotoMotion.uuid, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + id: motionAsset.id, visibility: AssetVisibility.Hidden, - libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, - localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, - originalFileName: 'asset_1.mp4', - originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), - ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, + libraryId: asset.libraryId, + localDateTime: asset.fileCreatedAt, + originalFileName: `IMG_${asset.id}.mp4`, + originalPath: expect.stringContaining(`${motionAsset.id}-MP.mp4`), + ownerId: asset.ownerId, type: AssetType.Video, }); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(motionAsset.originalPath, video); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoWithOriginalFileName.id, - livePhotoVideoId: fileStub.livePhotoMotion.uuid, + id: asset.id, + livePhotoVideoId: motionAsset.id, }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ name: JobName.AssetEncodeVideo, - data: { id: assetStub.livePhotoMotionAsset.id }, + data: { id: motionAsset.id }, }); }); it('should extract the motion photo video from the XMP directory entry ', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoWithOriginalFileName, - livePhotoVideoId: null, - libraryId: null, - }); + const asset = AssetFactory.create(); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, - mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); mockReadTags({ Directory: 'foo/bar/', @@ -778,47 +747,46 @@ describe(MetadataService.name, () => { MicroVideoOffset: 1, }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.asset.create.mockResolvedValue(motionAsset); + mocks.crypto.randomUUID.mockReturnValue(motionAsset.id); const video = randomBytes(512); mocks.storage.readFile.mockResolvedValue(video); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id); - expect(mocks.storage.readFile).toHaveBeenCalledWith( - assetStub.livePhotoWithOriginalFileName.originalPath, - expect.any(Object), - ); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); + expect(mocks.storage.readFile).toHaveBeenCalledWith(asset.originalPath, expect.any(Object)); expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', - fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, - fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, - id: fileStub.livePhotoMotion.uuid, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + id: motionAsset.id, visibility: AssetVisibility.Hidden, - libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, - localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, - originalFileName: 'asset_1.mp4', - originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), - ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, + libraryId: asset.libraryId, + localDateTime: asset.fileCreatedAt, + originalFileName: `IMG_${asset.id}.mp4`, + originalPath: expect.stringContaining(`${motionAsset.id}-MP.mp4`), + ownerId: asset.ownerId, type: AssetType.Video, }); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(motionAsset.originalPath, video); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoWithOriginalFileName.id, - livePhotoVideoId: fileStub.livePhotoMotion.uuid, + id: asset.id, + livePhotoVideoId: motionAsset.id, }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ name: JobName.AssetEncodeVideo, - data: { id: assetStub.livePhotoMotionAsset.id }, + data: { id: motionAsset.id }, }); }); it('should delete old motion photo video assets if they do not match what is extracted', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoWithOriginalFileName); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); + const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -826,21 +794,21 @@ describe(MetadataService.name, () => { MicroVideoOffset: 1, }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.create.mockImplementation( - (asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise, - ); + mocks.asset.create.mockResolvedValue(AssetFactory.create({ type: AssetType.Video })); const video = randomBytes(512); mocks.storage.readFile.mockResolvedValue(video); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.job.queue).toHaveBeenNthCalledWith(1, { name: JobName.AssetDelete, - data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, + data: { id: asset.livePhotoVideoId, deleteOnDisk: true }, }); }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); + const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -848,12 +816,12 @@ describe(MetadataService.name, () => { MicroVideoOffset: 1, }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getByChecksum.mockResolvedValue(motionAsset); const video = randomBytes(512); mocks.storage.readFile.mockResolvedValue(video); mocks.storage.checkFileExists.mockResolvedValue(true); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.create).not.toHaveBeenCalled(); expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); // The still asset gets saved by handleMetadataExtraction, but not the video @@ -862,10 +830,9 @@ 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 () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoStillAsset, - livePhotoVideoId: null, - }); + const motionAsset = AssetFactory.create({ type: AssetType.Video }); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -873,31 +840,26 @@ describe(MetadataService.name, () => { MicroVideoOffset: 1, }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.getByChecksum.mockResolvedValue({ - ...assetStub.livePhotoMotionAsset, - visibility: AssetVisibility.Timeline, - }); + mocks.asset.getByChecksum.mockResolvedValue(motionAsset); const video = randomBytes(512); mocks.storage.readFile.mockResolvedValue(video); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, + id: motionAsset.id, visibility: AssetVisibility.Hidden, }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + id: asset.id, + livePhotoVideoId: motionAsset.id, }); expect(mocks.asset.update).toHaveBeenCalledTimes(4); }); it('should not update storage usage if motion photo is external', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoStillAsset, - livePhotoVideoId: null, - isExternal: true, - }); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); + const asset = AssetFactory.create({ isExternal: true }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -905,16 +867,17 @@ describe(MetadataService.name, () => { MicroVideoOffset: 1, }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.create.mockResolvedValue(motionAsset); const video = randomBytes(512); mocks.storage.readFile.mockResolvedValue(video); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should save all metadata', async () => { const dateForTest = new Date('1970-01-01T00:00:00.000-11:30'); + const asset = AssetFactory.create(); const tags: ImmichTags = { BitsPerSample: 1, @@ -936,19 +899,19 @@ describe(MetadataService.name, () => { Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: 'UTC-11:30', + zone: 'UTC-11:30', TagsList: ['parent/child'], Rating: 3, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( { - assetId: assetStub.image.id, + assetId: asset.id, bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, @@ -972,7 +935,7 @@ describe(MetadataService.name, () => { orientation: tags.Orientation?.toString(), profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', - timeZone: tags.tz, + timeZone: tags.zone, rating: tags.Rating, country: null, state: null, @@ -983,7 +946,7 @@ describe(MetadataService.name, () => { ); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt: dateForTest, localDateTime: DateTime.fromISO('1970-01-01T00:00:00.000Z').toJSDate(), @@ -996,6 +959,7 @@ describe(MetadataService.name, () => { // https://github.com/photostructure/exiftool-vendored.js/issues/203 // this only tests our assumptions of exiftool-vendored, demonstrating the issue + const asset = AssetFactory.create(); 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 @@ -1003,13 +967,13 @@ describe(MetadataService.name, () => { const tags: ImmichTags = { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), - tz: undefined, + zone: undefined, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', @@ -1019,7 +983,8 @@ describe(MetadataService.name, () => { }); it('should extract duration', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1028,20 +993,21 @@ describe(MetadataService.name, () => { }, }); - await sut.handleMetadataExtraction({ id: assetStub.video.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: '00:00:06.210', }), ); }); it('should only extract duration for videos', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1049,20 +1015,21 @@ describe(MetadataService.name, () => { duration: 6.21, }, }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: null, }), ); }); it('should omit duration of zero', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1071,20 +1038,21 @@ describe(MetadataService.name, () => { }, }); - await sut.handleMetadataExtraction({ id: assetStub.video.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: null, }), ); }); it('should a handle duration of 1 week', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1093,65 +1061,55 @@ describe(MetadataService.name, () => { }, }); - await sut.handleMetadataExtraction({ id: assetStub.video.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.video.id, + id: asset.id, duration: '168:00:00.000', }), ); }); it('should use Duration from exif', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.image, - originalPath: '/original/path.webp', - }); + const asset = AssetFactory.create({ originalFileName: 'file.webp' }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Duration: 123 }, {}); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); }); it('should prefer Duration from exif over sidecar', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.image, - originalPath: '/original/path.webp', - files: [ - { - id: 'some-id', - type: AssetFileType.Sidecar, - path: '/path/to/something', - isEdited: false, - }, - ], - }); + const asset = AssetFactory.from({ originalFileName: 'file.webp' }).file({ type: AssetFileType.Sidecar }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Duration: 123 }, { Duration: 456 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(2); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); }); it('should ignore all Duration tags for definitely static images', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.imageDng); + const asset = AssetFactory.from({ originalFileName: 'file.dng' }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Duration: 123 }, { Duration: 456 }); - await sut.handleMetadataExtraction({ id: assetStub.imageDng.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); }); it('should ignore Duration from exif for videos', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Duration: 123 }, {}); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, @@ -1161,17 +1119,18 @@ describe(MetadataService.name, () => { }, }); - await sut.handleMetadataExtraction({ id: assetStub.video.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:07:36.000' })); }); it('should trim whitespace from description', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Description: '\t \v \f \n \r' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '', @@ -1180,7 +1139,7 @@ describe(MetadataService.name, () => { ); mockReadTags({ ImageDescription: ' my\n description' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: 'my\n description', @@ -1190,10 +1149,11 @@ describe(MetadataService.name, () => { }); it('should handle a numeric description', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Description: 1000 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '1000', @@ -1203,55 +1163,60 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata when the feature is disabled', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(makeFaceTags({ Name: 'Person 1' })); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing metadata face for assets without tags.RegionInfo', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing faces without name', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags()); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should skip importing faces with empty name', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: '' })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); - it('should apply metadata face tags creating new persons', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + it('should apply metadata face tags creating new people', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.update.mockResolvedValue(personStub.withName); - await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); - expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); expect(mocks.person.createAll).toHaveBeenCalledWith([ expect.objectContaining({ name: personStub.withName.name }), ]); @@ -1259,7 +1224,7 @@ describe(MetadataService.name, () => { [ { id: 'random-uuid', - assetId: assetStub.primaryImage.id, + assetId: asset.id, personId: 'random-uuid', imageHeight: 100, imageWidth: 1000, @@ -1273,7 +1238,7 @@ describe(MetadataService.name, () => { [], ); expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, + { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -1284,21 +1249,22 @@ describe(MetadataService.name, () => { }); it('should assign metadata face tags to existing persons', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.createAll.mockResolvedValue([]); mocks.person.update.mockResolvedValue(personStub.withName); - await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); - expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', - assetId: assetStub.primaryImage.id, + assetId: asset.id, personId: personStub.withName.id, imageHeight: 100, imageWidth: 1000, @@ -1368,16 +1334,17 @@ describe(MetadataService.name, () => { 'should transform RegionInfo geometry according to exif orientation $description', async ({ orientation, expected }) => { const { imgW, imgH, x1, x2, y1, y2 } = expected; + const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.update.mockResolvedValue(personStub.withName); - await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); - expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true, }); expect(mocks.person.createAll).toHaveBeenCalledWith([ @@ -1387,7 +1354,7 @@ describe(MetadataService.name, () => { [ { id: 'random-uuid', - assetId: assetStub.primaryImage.id, + assetId: asset.id, personId: 'random-uuid', imageWidth: imgW, imageHeight: imgH, @@ -1401,7 +1368,7 @@ describe(MetadataService.name, () => { [], ); expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, + { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -1414,10 +1381,11 @@ describe(MetadataService.name, () => { }); it('should handle invalid modify date', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ModifyDate: '00:00:00.000' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ modifyDate: expect.any(Date), @@ -1427,10 +1395,11 @@ describe(MetadataService.name, () => { }); it('should handle invalid rating value', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Rating: 6 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: null, @@ -1440,10 +1409,11 @@ describe(MetadataService.name, () => { }); it('should handle valid rating value', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Rating: 5 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: 5, @@ -1453,10 +1423,11 @@ describe(MetadataService.name, () => { }); it('should handle valid negative rating value', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Rating: -1 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: -1, @@ -1466,11 +1437,12 @@ describe(MetadataService.name, () => { }); it('should handle livePhotoCID not set', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ visibility: AssetVisibility.Hidden }), @@ -1479,17 +1451,18 @@ describe(MetadataService.name, () => { }); it('should handle not finding a match', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ContentIdentifier: 'CID' }); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: 'CID', - ownerId: assetStub.livePhotoMotionAsset.ownerId, - otherAssetId: assetStub.livePhotoMotionAsset.id, + ownerId: asset.ownerId, + otherAssetId: asset.id, libraryId: null, type: AssetType.Image, }); @@ -1500,65 +1473,67 @@ describe(MetadataService.name, () => { }); it('should link photo and video', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset); - mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + const motionAsset = AssetFactory.create({ type: AssetType.Video }); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ + libraryId: null, livePhotoCID: 'CID', - ownerId: assetStub.livePhotoStillAsset.ownerId, - otherAssetId: assetStub.livePhotoStillAsset.id, + ownerId: asset.ownerId, + otherAssetId: asset.id, type: AssetType.Video, }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + id: asset.id, + livePhotoVideoId: motionAsset.id, }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, + id: motionAsset.id, visibility: AssetVisibility.Hidden, }); - expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); + expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([motionAsset.id]); }); it('should notify clients on live photo link', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoStillAsset, - }); - mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + const motionAsset = AssetFactory.create({ type: AssetType.Video }); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { - userId: assetStub.livePhotoMotionAsset.ownerId, - assetId: assetStub.livePhotoMotionAsset.id, + userId: motionAsset.ownerId, + assetId: motionAsset.id, }); }); it('should search by libraryId', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.livePhotoStillAsset, - libraryId: 'library-id', - }); - mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + const motionAsset = AssetFactory.create({ type: AssetType.Video, libraryId: 'library-id' }); + const asset = AssetFactory.create({ libraryId: 'library-id' }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); - await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.event.emit).toHaveBeenCalledWith('AssetMetadataExtracted', { - assetId: assetStub.livePhotoStillAsset.id, - userId: assetStub.livePhotoStillAsset.ownerId, + assetId: asset.id, + userId: asset.ownerId, }); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ - ownerId: 'user-id', - otherAssetId: 'live-photo-still-asset', + ownerId: asset.ownerId, + otherAssetId: asset.id, livePhotoCID: 'CID', libraryId: 'library-id', - type: 'VIDEO', + type: AssetType.Video, }); }); @@ -1579,10 +1554,11 @@ describe(MetadataService.name, () => { }, { exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } }, ])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(exif); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), { lockedPropertiesBehavior: 'skip', }); @@ -1603,10 +1579,11 @@ describe(MetadataService.name, () => { { exif: { LensID: ' Unknown 6-30mm' }, expected: null }, { exif: { LensID: '' }, expected: null }, ])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(exif); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ lensModel: expected, @@ -1616,10 +1593,11 @@ describe(MetadataService.name, () => { }); it('should properly set width/height for normal images', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ width: 1000, @@ -1629,10 +1607,11 @@ describe(MetadataService.name, () => { }); it('should properly swap asset width/height for rotated images', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ width: 2000, @@ -1642,14 +1621,11 @@ describe(MetadataService.name, () => { }); it('should not overwrite existing width/height if they already exist', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.image, - width: 1920, - height: 1080, - }); + const asset = AssetFactory.create({ width: 1920, height: 1080 }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ width: 1280, @@ -1685,7 +1661,7 @@ describe(MetadataService.name, () => { it('should do nothing if asset could not be found', async () => { mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(void 0); - await expect(sut.handleSidecarCheck({ id: assetStub.image.id })).resolves.toBeUndefined(); + await expect(sut.handleSidecarCheck({ id: 'non-existent' })).resolves.toBeUndefined(); expect(mocks.asset.update).not.toHaveBeenCalled(); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4113025914..983d905aad 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -36,6 +36,10 @@ import { mergeTimeZone } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; +import { Tasks } from 'src/utils/tasks'; + +const POSTGRES_INT_MAX = 2_147_483_647; +const POSTGRES_INT_MIN = -2_147_483_648; /** look for a date from these tags (in order) */ const EXIF_DATE_TAGS: Array = [ @@ -89,7 +93,10 @@ const validate = (value: T): NonNullable | null => { return null; } - if (typeof value === 'number' && (Number.isNaN(value) || !Number.isFinite(value))) { + if ( + typeof value === 'number' && + (Number.isNaN(value) || !Number.isFinite(value) || value < POSTGRES_INT_MIN || value > POSTGRES_INT_MAX) + ) { return null; } @@ -282,8 +289,8 @@ export class MetadataService extends BaseService { colorspace: exifTags.ColorSpace ?? null, // camera - make: exifTags.Make ?? exifTags?.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, - model: exifTags.Model ?? exifTags?.Device?.ModelName ?? exifTags.AndroidModel ?? null, + make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, + model: exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? null, fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, @@ -307,33 +314,38 @@ export class MetadataService extends BaseService { const assetWidth = isSidewards ? validate(height) : validate(width); const assetHeight = isSidewards ? validate(width) : validate(height); - const promises: Promise[] = [ - this.assetRepository.update({ - id: asset.id, - duration: this.getDuration(exifTags), - localDateTime: dates.localDateTime, - fileCreatedAt: dates.dateTimeOriginal ?? undefined, - fileModifiedAt: stats.mtime, + const tasks = new Tasks(); - // only update the dimensions if they don't already exist - // we don't want to overwrite width/height that are modified by edits - width: asset.width == null ? assetWidth : undefined, - height: asset.height == null ? assetHeight : undefined, - }), - ]; + tasks.push( + () => + this.assetRepository.update({ + id: asset.id, + duration: this.getDuration(exifTags), + localDateTime: dates.localDateTime, + fileCreatedAt: dates.dateTimeOriginal ?? undefined, + fileModifiedAt: stats.mtime, - await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }); - await this.applyTagList(asset); + // only update the dimensions if they don't already exist + // we don't want to overwrite width/height that are modified by edits + width: asset.width == null ? assetWidth : undefined, + height: asset.height == null ? assetHeight : undefined, + }), + async () => { + await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }); + await this.applyTagList(asset); + }, + ); if (this.isMotionPhoto(asset, exifTags)) { - promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); + tasks.push(() => this.applyMotionPhotos(asset, exifTags, dates, stats)); } if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { - promises.push(this.applyTaggedFaces(asset, exifTags)); + tasks.push(() => this.applyTaggedFaces(asset, exifTags)); } - await Promise.all(promises); + await tasks.all(); + if (exifData.livePhotoCID) { await this.linkLivePhotos(asset, exifData); } @@ -527,6 +539,15 @@ export class MetadataService extends BaseService { for (const tag of EXIF_DATE_TAGS) { delete mediaTags[tag]; } + + // exiftool-vendored derives tz information from the date. + // if the sidecar file has date information, we also assume the tz information come from there. + // + // this is especially important in the case of UTC+0 where exiftool-vendored does not return tz/zone fields + // and as such the tags aren't overwritten when returning all tags. + for (const tag of ['zone', 'tz', 'tzSource'] as const) { + delete mediaTags[tag]; + } } } @@ -569,10 +590,10 @@ export class MetadataService extends BaseService { } private async applyTagList({ id, ownerId }: { id: string; ownerId: string }) { - const asset = await this.assetRepository.getById(id, { exifInfo: true }); + const asset = await this.assetRepository.getForMetadataExtractionTags(id); const results = await upsertTags(this.tagRepository, { userId: ownerId, - tags: asset?.exifInfo?.tags ?? [], + tags: asset?.tags ?? [], }); await this.tagRepository.replaceAssetTags( id, @@ -897,8 +918,8 @@ export class MetadataService extends BaseService { } // timezone - let timeZone = exifTags.tz ?? null; - if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + let timeZone = exifTags.zone ?? null; + if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) { // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly // https://github.com/photostructure/exiftool-vendored.js/issues/203 timeZone = 'UTC+0'; @@ -906,7 +927,7 @@ export class MetadataService extends BaseService { if (timeZone) { this.logger.verbose( - `Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, + `Found timezone ${timeZone} via ${exifTags.zoneSource} for asset ${asset.id}: ${asset.originalPath}`, ); } else { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index bece89d73e..ee4b4ec05f 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,6 +6,7 @@ import { NotificationService } from 'src/services/notification.service'; import { INotifyAlbumUpdateJob } from 'src/types'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFileFactory } from 'test/factories/asset-file.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; import { UserFactory } from 'test/factories/user.factory'; import { notificationStub } from 'test/fixtures/notification.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -392,8 +393,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail and arbitrary extension', async () => { - const assetFile = AssetFileFactory.create({ path: 'some-thumb.ext', type: AssetFileType.Thumbnail }); - const album = AlbumFactory.create({ albumThumbnailAssetId: assetFile.assetId }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).asset(asset).build(); mocks.album.getById.mockResolvedValue(album); mocks.user.get.mockResolvedValue({ ...userStub.user1, @@ -407,7 +408,7 @@ describe(NotificationService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetFile]); + mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([asset.files[0]]); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( @@ -418,7 +419,7 @@ describe(NotificationService.name, () => { name: JobName.SendMail, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), - imageAttachments: [{ filename: 'album-thumbnail.ext', path: expect.anything(), cid: expect.anything() }], + imageAttachments: [{ filename: 'album-thumbnail.jpg', path: expect.anything(), cid: expect.anything() }], }), }); }); diff --git a/server/src/services/ocr.service.spec.ts b/server/src/services/ocr.service.spec.ts index 404f423cac..d5b146e942 100644 --- a/server/src/services/ocr.service.spec.ts +++ b/server/src/services/ocr.service.spec.ts @@ -1,6 +1,6 @@ -import { AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { OcrService } from 'src/services/ocr.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -14,7 +14,7 @@ describe(OcrService.name, () => { mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Timeline, - previewFile: assetStub.image.files[1].path, + previewFile: '/uploads/user-id/thumbs/path.jpg', }); }); @@ -41,20 +41,22 @@ describe(OcrService.name, () => { }); it('should queue the assets without ocr', async () => { - mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([asset])); await sut.handleQueueOcr({ force: false }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: assetStub.image.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: asset.id } }]); expect(mocks.assetJob.streamForOcrJob).toHaveBeenCalledWith(false); }); it('should queue all the assets', async () => { - mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([asset])); await sut.handleQueueOcr({ force: true }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: assetStub.image.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: asset.id } }]); expect(mocks.assetJob.streamForOcrJob).toHaveBeenCalledWith(true); }); }); @@ -70,15 +72,17 @@ describe(OcrService.name, () => { }); it('should skip assets without a resize path', async () => { + const asset = AssetFactory.create(); mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Timeline, previewFile: null }); - expect(await sut.handleOcr({ id: assetStub.noResizePath.id })).toEqual(JobStatus.Failed); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Failed); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { + const asset = AssetFactory.create(); mocks.machineLearning.ocr.mockResolvedValue({ box: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160], boxScore: [0.9, 0.8], @@ -86,7 +90,7 @@ describe(OcrService.name, () => { textScore: [0.95, 0.85], }); - expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.ocr).toHaveBeenCalledWith( '/uploads/user-id/thumbs/path.jpg', @@ -98,10 +102,10 @@ describe(OcrService.name, () => { }), ); expect(mocks.ocr.upsert).toHaveBeenCalledWith( - assetStub.image.id, + asset.id, [ { - assetId: assetStub.image.id, + assetId: asset.id, boxScore: 0.9, text: 'One Two Three', textScore: 0.95, @@ -115,7 +119,7 @@ describe(OcrService.name, () => { y4: 80, }, { - assetId: assetStub.image.id, + assetId: asset.id, boxScore: 0.8, text: 'Four Five', textScore: 0.85, @@ -134,6 +138,7 @@ describe(OcrService.name, () => { }); it('should apply config settings', async () => { + const asset = AssetFactory.create(); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, @@ -148,7 +153,7 @@ describe(OcrService.name, () => { }); mockOcrResult(); - expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.ocr).toHaveBeenCalledWith( '/uploads/user-id/thumbs/path.jpg', @@ -159,16 +164,17 @@ describe(OcrService.name, () => { maxResolution: 1500, }), ); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [], ''); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, [], ''); }); it('should skip invisible assets', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Hidden, - previewFile: assetStub.image.files[1].path, + previewFile: asset.files[0].path, }); - expect(await sut.handleOcr({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.Skipped); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Skipped); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); @@ -177,7 +183,7 @@ describe(OcrService.name, () => { it('should fail if asset could not be found', async () => { mocks.assetJob.getForOcr.mockResolvedValue(void 0); - expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Failed); + expect(await sut.handleOcr({ id: 'non-existent' })).toEqual(JobStatus.Failed); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); @@ -185,79 +191,84 @@ describe(OcrService.name, () => { describe('search tokenization', () => { it('should generate bigrams for Chinese text', async () => { + const asset = AssetFactory.create(); mockOcrResult('抟器學įŋ’'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '抟器 器學 å­¸įŋ’'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '抟器 器學 å­¸įŋ’'); }); it('should generate bigrams for Japanese text', async () => { + const asset = AssetFactory.create(); mockOcrResult('テ゚ト'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'テ゚ ゚ト'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'テ゚ ゚ト'); }); it('should generate bigrams for Korean text', async () => { + const asset = AssetFactory.create(); mockOcrResult('한ęĩ­ė–´'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '한ęĩ­ ęĩ­ė–´'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '한ęĩ­ ęĩ­ė–´'); }); it('should pass through Latin text unchanged', async () => { + const asset = AssetFactory.create(); mockOcrResult('Hello World'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'Hello World'); }); it('should handle mixed CJK and Latin text', async () => { + const asset = AssetFactory.create(); mockOcrResult('抟器學įŋ’Model'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '抟器 器學 å­¸įŋ’ Model'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '抟器 器學 å­¸įŋ’ Model'); }); it('should handle year followed by CJK', async () => { + const asset = AssetFactory.create(); mockOcrResult('2024åš´ãƒŦポãƒŧト'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith( - assetStub.image.id, - expect.any(Array), - '2024 åš´ãƒŦ ãƒŦポ ポãƒŧ ãƒŧト', - ); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '2024 åš´ãƒŦ ãƒŦポ ポãƒŧ ãƒŧト'); }); it('should join multiple OCR boxes', async () => { + const asset = AssetFactory.create(); mockOcrResult('抟器', 'Learning'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '抟器 Learning'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '抟器 Learning'); }); it('should normalize whitespace', async () => { + const asset = AssetFactory.create(); mockOcrResult(' Hello World '); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'Hello World'); }); it('should keep single CJK characters', async () => { + const asset = AssetFactory.create(); mockOcrResult('A', '中', 'B'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'A 中 B'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'A 中 B'); }); }); }); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index b57a5e1072..d7c9fa9f59 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,17 +1,20 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; -import { CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; -import { DetectedFaces } from 'src/repositories/machine-learning.repository'; +import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PersonFactory } from 'test/factories/person.factory'; +import { UserFactory } from 'test/factories/user.factory'; 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 { factory } from 'test/small.factory'; +import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers'; +import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { @@ -27,35 +30,6 @@ 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: face.boundingBoxX1, - y1: face.boundingBoxY1, - x2: face.boundingBoxX2, - y2: face.boundingBoxY2, - }, - embedding: faceSearch.embedding, - score: 0.2, - }, - ], - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, -}; - describe(PersonService.name, () => { let sut: PersonService; let mocks: ServiceMocks; @@ -259,27 +233,25 @@ describe(PersonService.name, () => { }); it("should update a person's thumbnailPath", async () => { + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); mocks.person.update.mockResolvedValue(personStub.withName); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect( - sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), - ).resolves.toEqual(responseDto); + await expect(sut.update(auth, 'person-1', { featureFaceAssetId: face.assetId })).resolves.toEqual(responseDto); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); - expect(mocks.person.getFacesByIds).toHaveBeenCalledWith([ - { - assetId: faceStub.face1.assetId, - personId: 'person-1', - }, - ]); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: face.id }); + expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ + assetId: face.assetId, + personId: 'person-1', + }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonGenerateThumbnail, data: { id: 'person-1' }, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { @@ -319,19 +291,21 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should reassign a face', async () => { + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); mocks.person.getById.mockResolvedValue(personStub.noName); - mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); + mocks.person.getFacesByIds.mockResolvedValue([face]); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.refreshFaces.mockResolvedValue(); mocks.person.reassignFace.mockResolvedValue(5); mocks.person.update.mockResolvedValue(personStub.noName); await expect( - sut.reassignFaces(authStub.admin, personStub.noName.id, { - data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], + sut.reassignFaces(auth, personStub.noName.id, { + data: [{ personId: personStub.withName.id, assetId: face.assetId }], }), ).resolves.toBeDefined(); @@ -352,17 +326,20 @@ describe(PersonService.name, () => { describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); - mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); - mocks.asset.getById.mockResolvedValue(assetStub.image); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ - mapFaces(faceStub.primaryFace1, authStub.admin), - ]); + const auth = AuthFactory.create(); + const face = AssetFaceFactory.create(); + const asset = AssetFactory.from({ id: face.assetId }).exif().build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.person.getFaces.mockResolvedValue([face]); + mocks.asset.getForFaces.mockResolvedValue({ edits: [], ...asset.exifInfo }); + await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([mapFaces(face, auth)]); }); + it('should reject if the user has not access to the asset', async () => { + const face = AssetFaceFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); - mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( + mocks.person.getFaces.mockResolvedValue([face]); + await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf( BadRequestException, ); }); @@ -370,7 +347,7 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -383,38 +360,38 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { + const face = AssetFaceFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); + mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(personStub.noName); - await expect( - sut.reassignFacesById(authStub.admin, personStub.noName.id, { - id: faceStub.face1.id, - }), - ).resolves.toEqual({ - birthDate: personStub.noName.birthDate, - isHidden: personStub.noName.isHidden, - isFavorite: personStub.noName.isFavorite, - id: personStub.noName.id, - name: personStub.noName.name, - thumbnailPath: personStub.noName.thumbnailPath, - updatedAt: expect.any(Date), - color: personStub.noName.color, - }); + await expect(sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id })).resolves.toEqual( + { + birthDate: personStub.noName.birthDate, + isHidden: personStub.noName.isHidden, + isFavorite: personStub.noName.isFavorite, + id: personStub.noName.id, + name: personStub.noName.name, + thumbnailPath: personStub.noName.thumbnailPath, + updatedAt: expect.any(Date), + color: personStub.noName.color, + }, + ); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should fail if user has not the correct permissions on the asset', async () => { + const face = AssetFaceFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(personStub.noName); await expect( - sut.reassignFacesById(authStub.admin, personStub.noName.id, { - id: faceStub.face1.id, + sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { + id: face.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -455,7 +432,8 @@ describe(PersonService.name, () => { }); it('should queue missing assets', async () => { - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); await sut.handleQueueDetectFaces({ force: false }); @@ -464,13 +442,14 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should queue all assets', async () => { - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); await sut.handleQueueDetectFaces({ force: true }); @@ -483,13 +462,14 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should refresh all assets', async () => { - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); await sut.handleQueueDetectFaces({ force: undefined }); @@ -501,16 +481,18 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonCleanup }); }); it('should delete existing people and faces if forced', async () => { - mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + const face = AssetFaceFactory.from().person().build(); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.deleteFaces.mockResolvedValue(); @@ -520,7 +502,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); @@ -563,6 +545,7 @@ describe(PersonService.name, () => { }); it('should queue missing assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -571,7 +554,7 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); @@ -583,7 +566,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -593,6 +576,7 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -602,7 +586,7 @@ describe(PersonService.name, () => { delayed: 0, }); mocks.person.getAll.mockReturnValue(makeStream()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); @@ -611,7 +595,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -621,8 +605,9 @@ describe(PersonService.name, () => { }); it('should run nightly if new face has been added since last run', async () => { + const face = AssetFaceFactory.create(); mocks.person.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -632,7 +617,7 @@ describe(PersonService.name, () => { delayed: 0, }); mocks.person.getAll.mockReturnValue(makeStream()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); mocks.person.unassignFaces.mockResolvedValue(); @@ -647,7 +632,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -661,7 +646,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([AssetFaceFactory.create()])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -675,6 +660,7 @@ describe(PersonService.name, () => { }); it('should delete existing people if forced', async () => { + const face = AssetFaceFactory.from().person().build(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -683,8 +669,8 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.unassignFaces.mockResolvedValue(); @@ -695,7 +681,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); @@ -705,10 +691,6 @@ describe(PersonService.name, () => { }); describe('handleDetectFaces', () => { - beforeEach(() => { - mocks.crypto.randomUUID.mockReturnValue(faceId); - }); - it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -718,26 +700,28 @@ describe(PersonService.name, () => { }); it('should skip when no resize path', async () => { - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.noResizePath, files: [] }); - await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); + const asset = AssetFactory.create(); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should handle no results', async () => { const start = Date.now(); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); - await sut.handleDetectFaces({ id: assetStub.image.id }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', + asset.files[0].path, expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ - assetId: assetStub.image.id, + assetId: asset.id, facesRecognizedAt: expect.any(Date), }); const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; @@ -745,93 +729,105 @@ describe(PersonService.name, () => { }); it('should create a face with no person and queue recognition job', async () => { - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + const face = AssetFaceFactory.create({ assetId: asset.id }); + mocks.crypto.randomUUID.mockReturnValue(face.id); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); + mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should delete an existing face not among the new detected faces', async () => { + const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.primaryFace1], - files: [assetStub.image.files[1]], - }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [asset.faces[0].id], []); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add new face and delete an existing face not among the new detected faces', async () => { - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.primaryFace1], - files: [assetStub.image.files[1]], + const assetId = newUuid(); + const face = AssetFaceFactory.create({ + assetId, + boundingBoxX1: 200, + boundingBoxX2: 300, + boundingBoxY1: 200, + boundingBoxY2: 300, }); + const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.crypto.randomUUID.mockReturnValue(face.id); mocks.person.refreshFaces.mockResolvedValue(); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [asset.faces[0].id], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add embedding to matching metadata face', async () => { - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.fromExif1], - files: [assetStub.image.files[1]], - }); + const face = AssetFaceFactory.create({ sourceType: SourceType.Exif }); + const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith( - [], - [], - [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], - ); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [], [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }]); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not add embedding to non-matching metadata face', async () => { - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.fromExif2], - files: [assetStub.image.files[1]], - }); + const assetId = newUuid(); + const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif }); + const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.crypto.randomUUID.mockReturnValue(face.id); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); @@ -840,153 +836,172 @@ describe(PersonService.name, () => { describe('handleRecognizeFaces', () => { it('should fail if face does not exist', async () => { - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); + expect(await sut.handleRecognizeFaces({ id: 'unknown-face' })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { - const face = { ...faceStub.face1, asset: null }; - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face); + const face = AssetFaceFactory.create(); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, null)); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); + expect(await sut.handleRecognizeFaces({ id: face.id })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1); + const asset = AssetFactory.create(); + const face = AssetFaceFactory.from({ assetId: asset.id }).person().build(); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Skipped); + expect(await sut.handleRecognizeFaces({ id: face.id })).toBe(JobStatus.Skipped); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should match existing person', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + + const [noPerson1, noPerson2, primaryFace, face] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.create(), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person().build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.primaryFace1, distance: 0.2 }, - { ...faceStub.noPerson2, distance: 0.3 }, - { ...faceStub.face1, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...primaryFace, distance: 0.2 }, + { ...noPerson2, distance: 0.3 }, + { ...face, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(primaryFace.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.arrayContaining([noPerson1.id]), + newPersonId: primaryFace.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: primaryFace.person!.id, }); }); it('should match existing person if their birth date is unknown', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + const [noPerson, face, faceWithBirthDate] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.primaryFace1, distance: 0.2 }, - { ...faceStub.withBirthDate, distance: 0.3 }, + { ...noPerson, distance: 0 }, + { ...face, distance: 0.2 }, + { ...faceWithBirthDate, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.create.mockResolvedValue(face.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.arrayContaining([noPerson.id]), + newPersonId: face.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: face.person!.id, }); }); it('should match existing person if their birth date is before file creation', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + const [noPerson, face, faceWithBirthDate] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.withBirthDate, distance: 0.2 }, - { ...faceStub.primaryFace1, distance: 0.3 }, + { ...noPerson, distance: 0 }, + { ...faceWithBirthDate, distance: 0.2 }, + { ...face, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.create.mockResolvedValue(face.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.withBirthDate.person?.id, + faceIds: expect.arrayContaining([noPerson.id]), + newPersonId: faceWithBirthDate.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.withBirthDate.person?.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: faceWithBirthDate.person!.id, }); }); it('should create a new person if the face is a core point with no person', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const person = PersonFactory.create(); + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.3 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(person); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).toHaveBeenCalledWith({ - ownerId: faceStub.noPerson1.asset.ownerId, - faceAssetId: faceStub.noPerson1.id, + ownerId: asset.ownerId, + faceAssetId: noPerson1.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: [faceStub.noPerson1.id], - newPersonId: personStub.withName.id, + faceIds: [noPerson1.id], + newPersonId: person.id, }); }); it('should not queue face with no matches', async () => { - const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; + const asset = AssetFactory.create(); + const face = AssetFaceFactory.create({ assetId: asset.id }); + const faces = [{ ...face, distance: 0 }] as FaceSearchResult[]; mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: face.id }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); @@ -995,21 +1010,24 @@ describe(PersonService.name, () => { }); it('should defer non-core faces to end of queue', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognition, - data: { id: faceStub.noPerson1.id, deferred: true }, + data: { id: noPerson1.id, deferred: true }, }); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); expect(mocks.person.create).not.toHaveBeenCalled(); @@ -1017,17 +1035,20 @@ describe(PersonService.name, () => { }); it('should not assign person to deferred non-core face with no matching person', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); + await sut.handleRecognizeFaces({ id: noPerson1.id, deferred: true }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(2); @@ -1152,26 +1173,30 @@ describe(PersonService.name, () => { describe('mapFace', () => { it('should map a face', () => { - const authDto = factory.auth({ user: { id: faceStub.face1.person.ownerId } }); - expect(mapFaces(faceStub.face1, authDto)).toEqual({ - boundingBoxX1: 0, - boundingBoxX2: 1, - boundingBoxY1: 0, - boundingBoxY2: 1, - id: faceStub.face1.id, - imageHeight: 1024, - imageWidth: 1024, + const user = UserFactory.create(); + const auth = AuthFactory.create({ id: user.id }); + const person = PersonFactory.create({ ownerId: user.id }); + const face = AssetFaceFactory.from().person(person).build(); + + expect(mapFaces(face, auth)).toEqual({ + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, + id: face.id, + imageHeight: 500, + imageWidth: 400, sourceType: SourceType.MachineLearning, - person: mapPerson(personStub.withName), + person: mapPerson(person), }); }); it('should not map person if person is null', () => { - expect(mapFaces({ ...faceStub.face1, person: null }, authStub.user1).person).toBeNull(); + expect(mapFaces(AssetFaceFactory.create(), AuthFactory.create()).person).toBeNull(); }); it('should not map person if person does not match auth user id', () => { - expect(mapFaces(faceStub.face1, authStub.user1).person).toBeNull(); + expect(mapFaces(AssetFaceFactory.from().person().build(), AuthFactory.create()).person).toBeNull(); }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e63dcedb7d..090b358223 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -128,10 +128,10 @@ export class PersonService extends BaseService { async getFacesById(auth: AuthDto, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); const faces = await this.personRepository.getFaces(dto.id); - const asset = await this.assetRepository.getById(dto.id, { edits: true, exifInfo: true }); - const assetDimensions = getDimensions(asset!.exifInfo!); + const asset = await this.assetRepository.getForFaces(dto.id); + const assetDimensions = getDimensions(asset); - return faces.map((face) => mapFaces(face, auth, asset!.edits!, assetDimensions)); + return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions)); } async createNewFeaturePhoto(changeFeaturePhoto: string[]) { @@ -197,13 +197,9 @@ export class PersonService extends BaseService { let faceId: string | undefined = undefined; if (assetId) { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] }); - const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); + const face = await this.personRepository.getForFeatureFaceUpdate({ personId: id, assetId }); if (!face) { - throw new BadRequestException('Invalid assetId for feature face'); - } - - if (face.asset.isOffline) { - throw new BadRequestException('An offline asset cannot be used for feature face'); + throw new BadRequestException('Invalid assetId for feature face or asset is offline'); } faceId = face.id; diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 0dec02f18f..5f1125eaed 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -2,7 +2,8 @@ import { BadRequestException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; import { SearchService } from 'src/services/search.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -64,16 +65,18 @@ describe(SearchService.name, () => { describe('getExploreData', () => { it('should get assets by city and tag', async () => { + const auth = AuthFactory.create(); + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); mocks.asset.getAssetIdByCity.mockResolvedValue({ fieldName: 'exifInfo.city', - items: [{ value: 'test-city', data: assetStub.withLocation.id }], + items: [{ value: 'city', data: asset.id }], }); - mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]); - const expectedResponse = [ - { fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] }, - ]; + mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]); + const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }]; - const result = await sut.getExploreData(authStub.user1); + const result = await sut.getExploreData(auth); expect(result).toEqual(expectedResponse); }); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 8c0e336d5b..5ad145af2b 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -1,10 +1,10 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; -import _ from 'lodash'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import { AlbumFactory } from 'test/factories/album.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { factory } from 'test/small.factory'; @@ -35,14 +35,14 @@ describe(SharedLinkService.name, () => { describe('getMine', () => { it('should only work for a public user', async () => { - await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); + await expect(sut.getMine(authStub.admin, [])).rejects.toBeInstanceOf(ForbiddenException); expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); + await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); @@ -55,21 +55,22 @@ describe(SharedLinkService.name, () => { }, }); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); - const response = await sut.getMine(authDto, {}); + const response = await sut.getMine(authDto, []); expect(response.assets[0]).toMatchObject({ hasMetadata: false }); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); - it('should throw an error for an invalid password protected shared link', async () => { + it('should throw an error for a request without a shared link auth token', async () => { const authDto = authStub.adminSharedLink; mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired); - await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect(sut.getMine(authDto, [])).rejects.toBeInstanceOf(UnauthorizedException); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); - it('should allow a correct password on a password protected shared link', async () => { + it('should accept a valid shared link auth token', async () => { mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); - await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); + mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token'); + await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined(); expect(mocks.sharedLink.get).toHaveBeenCalledWith( authStub.adminSharedLink.user.id, authStub.adminSharedLink.sharedLink?.id, @@ -142,12 +143,13 @@ describe(SharedLinkService.name, () => { }); it('should create an individual shared link', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.Individual, - assetIds: [assetStub.image.id], + assetIds: [asset.id], showMetadata: true, allowDownload: true, allowUpload: true, @@ -155,7 +157,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set([assetStub.image.id]), + new Set([asset.id]), false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ @@ -165,7 +167,7 @@ describe(SharedLinkService.name, () => { allowDownload: true, slug: null, allowUpload: true, - assetIds: [assetStub.image.id], + assetIds: [asset.id], description: null, expiresAt: null, showExif: true, @@ -174,12 +176,13 @@ describe(SharedLinkService.name, () => { }); it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.Individual, - assetIds: [assetStub.image.id], + assetIds: [asset.id], showMetadata: false, allowDownload: true, allowUpload: true, @@ -187,7 +190,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set([assetStub.image.id]), + new Set([asset.id]), false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ @@ -196,7 +199,7 @@ describe(SharedLinkService.name, () => { albumId: null, allowDownload: false, allowUpload: true, - assetIds: [assetStub.image.id], + assetIds: [asset.id], description: null, expiresAt: null, showExif: false, @@ -263,25 +266,28 @@ describe(SharedLinkService.name, () => { }); it('should add assets to a shared link', async () => { - mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); + const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from().asset(asset).build(); + const newAsset = AssetFactory.create(); + mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.create.mockResolvedValue(sharedLink); + mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([newAsset.id])); await expect( - sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), + sut.addAssets(authStub.admin, sharedLink.id, { assetIds: [asset.id, 'asset-2', newAsset.id] }), ).resolves.toEqual([ - { assetId: assetStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE }, + { assetId: asset.id, success: false, error: AssetIdErrorReason.DUPLICATE }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION }, - { assetId: 'asset-3', success: true }, + { assetId: newAsset.id, success: true }, ]); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ - ...sharedLinkStub.individual, + ...sharedLink, slug: null, - assetIds: ['asset-3'], + assetIds: [newAsset.id], }); }); }); @@ -296,20 +302,22 @@ describe(SharedLinkService.name, () => { }); it('should remove assets from a shared link', async () => { - mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); - mocks.sharedLinkAsset.remove.mockResolvedValue([assetStub.image.id]); + const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from().asset(asset).build(); + mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.create.mockResolvedValue(sharedLink); + mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]); await expect( - sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), + sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: [asset.id, 'asset-2'] }), ).resolves.toEqual([ - { assetId: assetStub.image.id, success: true }, + { assetId: asset.id, success: true }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); - expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith('link-1', [assetStub.image.id, 'asset-2']); - expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); + expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith(sharedLink.id, [asset.id, 'asset-2']); + expect(mocks.sharedLink.update).toHaveBeenCalledWith(expect.objectContaining({ assets: [] })); }); }); @@ -333,7 +341,7 @@ describe(SharedLinkService.name, () => { await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', - imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, + imageUrl: `https://my.immich.app/api/assets/${sharedLinkStub.individual.assets[0].id}/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 1440598084..e321e4990d 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { PostgresError } from 'postgres'; -import { SharedLink } from 'src/database'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -8,7 +7,7 @@ import { mapSharedLink, SharedLinkCreateDto, SharedLinkEditDto, - SharedLinkPasswordDto, + SharedLinkLoginDto, SharedLinkResponseDto, SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; @@ -24,18 +23,41 @@ export class SharedLinkService extends BaseService { .then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false }))); } - async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise { + async login(auth: AuthDto, dto: SharedLinkLoginDto) { if (!auth.sharedLink) { throw new ForbiddenException(); } const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id); - const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }); - if (sharedLink.password) { - response.token = this.validateAndRefreshToken(sharedLink, dto); + const { id, password } = sharedLink; + + if (!password) { + throw new BadRequestException('Shared link is not password protected'); } - return response; + if (password !== dto.password) { + throw new UnauthorizedException('Invalid password'); + } + + return { + sharedLink: mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }), + token: this.asToken({ id, password }), + }; + } + + async getMine(auth: AuthDto, authTokens: string[]) { + if (!auth.sharedLink) { + throw new ForbiddenException(); + } + + const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id); + const { id, password } = sharedLink; + + if (password && !authTokens.includes(this.asToken({ id, password }))) { + throw new UnauthorizedException('Password required'); + } + + return mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }); } async get(auth: AuthDto, id: string): Promise { @@ -213,16 +235,7 @@ export class SharedLinkService extends BaseService { }; } - private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string { - const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`); - const sharedLinkTokens = dto.token?.split(',') || []; - if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) { - throw new UnauthorizedException('Invalid password'); - } - - if (!sharedLinkTokens.includes(token)) { - sharedLinkTokens.push(token); - } - return sharedLinkTokens.join(','); + private asToken(sharedLink: { id: string; password: string }) { + return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`); } } diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index b3af5cd15f..6bd0a3c9b2 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,8 +1,8 @@ import { SystemConfig } from 'src/config'; -import { ImmichWorker, JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -13,7 +13,7 @@ describe(SmartInfoService.name, () => { beforeEach(() => { ({ sut, mocks } = newTestService(SmartInfoService)); - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([AssetFactory.create()]); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); }); @@ -155,25 +155,23 @@ describe(SmartInfoService.name, () => { }); it('should queue the assets without clip embeddings', async () => { - mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([asset])); await sut.handleQueueEncodeClip({ force: false }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SmartSearch, data: { id: assetStub.image.id } }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SmartSearch, data: { id: asset.id } }]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false); expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); }); it('should queue all the assets', async () => { - mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([asset])); await sut.handleQueueEncodeClip({ force: true }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SmartSearch, data: { id: assetStub.image.id } }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SmartSearch, data: { id: asset.id } }]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true); expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512); }); @@ -190,34 +188,36 @@ describe(SmartInfoService.name, () => { }); it('should skip assets without a resize path', async () => { - mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.noResizePath, files: [] }); + const asset = AssetFactory.create(); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.Failed); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Failed); expect(mocks.search.upsert).not.toHaveBeenCalled(); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); - mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', + asset.files[0].path, expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(asset.id, '[0.01, 0.02, 0.03]'); }); it('should skip invisible assets', async () => { - mocks.assetJob.getForClipEncoding.mockResolvedValue({ - ...assetStub.livePhotoMotionAsset, - files: [assetStub.image.files[1]], - }); + const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }) + .file({ type: AssetFileType.Preview }) + .build(); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.Skipped); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Skipped); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(mocks.search.upsert).not.toHaveBeenCalled(); @@ -226,25 +226,26 @@ describe(SmartInfoService.name, () => { it('should fail if asset could not be found', async () => { mocks.assetJob.getForClipEncoding.mockResolvedValue(void 0); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Failed); + expect(await sut.handleEncodeClip({ id: 'non-existent' })).toEqual(JobStatus.Failed); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should wait for database', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); mocks.database.isBusy.mockReturnValue(true); - mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.database.wait).toHaveBeenCalledWith(512); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', + asset.files[0].path, expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(asset.id, '[0.01, 0.02, 0.03]'); }); }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 1dc87f4348..93f84e28e1 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -1,6 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { StackService } from 'src/services/stack.service'; -import { assetStub, stackStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { StackFactory } from 'test/factories/stack.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -19,43 +21,49 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { - mocks.stack.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); + const auth = AuthFactory.create(); + const asset = AssetFactory.create(); + const stack = StackFactory.from().primaryAsset(asset).build(); + mocks.stack.search.mockResolvedValue([stack]); - await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); + await sut.search(auth, { primaryAssetId: asset.id }); expect(mocks.stack.search).toHaveBeenCalledWith({ - ownerId: authStub.admin.user.id, - primaryAssetId: assetStub.image.id, + ownerId: auth.user.id, + primaryAssetId: asset.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); + const auth = AuthFactory.create(); + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; + + await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); expect(mocks.stack.create).not.toHaveBeenCalled(); }); it('should create a stack', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); - mocks.stack.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); - await expect( - sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), - ).resolves.toEqual({ - id: 'stack-id', - primaryAssetId: assetStub.image.id, - assets: [ - expect.objectContaining({ id: assetStub.image.id }), - expect.objectContaining({ id: assetStub.image1.id }), - ], + const auth = AuthFactory.create(); + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; + const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id])); + mocks.stack.create.mockResolvedValue(stack); + + await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({ + id: stack.id, + primaryAssetId: primaryAsset.id, + assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })], }); expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', { - stackId: 'stack-id', - userId: authStub.admin.user.id, + stackId: stack.id, + userId: auth.user.id, }); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); }); @@ -79,25 +87,26 @@ describe(StackService.name, () => { }); it('should get stack', async () => { - mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + const auth = AuthFactory.create(); + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; + const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); - 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 }), - ], + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); + mocks.stack.getById.mockResolvedValue(stack); + + await expect(sut.get(auth, stack.id)).resolves.toEqual({ + id: stack.id, + primaryAssetId: primaryAsset.id, + assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })], }); expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); }); }); describe('update', () => { it('should require stack.update permissions', async () => { - await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); expect(mocks.stack.getById).not.toHaveBeenCalled(); expect(mocks.stack.update).not.toHaveBeenCalled(); @@ -107,7 +116,7 @@ describe(StackService.name, () => { it('should fail if stack could not be found', async () => { mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error); + await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(Error); expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(mocks.stack.update).not.toHaveBeenCalled(); @@ -115,55 +124,64 @@ describe(StackService.name, () => { }); it('should fail if the provided primary asset id is not in the stack', async () => { - mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + const auth = AuthFactory.create(); + const stack = StackFactory.from().primaryAsset().asset().build(); - await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); + mocks.stack.getById.mockResolvedValue(stack); + + await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); expect(mocks.stack.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should update stack', async () => { - mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); - mocks.stack.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + const auth = AuthFactory.create(); + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; + const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); - await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); + mocks.stack.getById.mockResolvedValue(stack); + mocks.stack.update.mockResolvedValue(stack); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); - expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { - id: 'stack-id', - primaryAssetId: assetStub.image1.id, + await sut.update(auth, stack.id, { primaryAssetId: asset.id }); + + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); + expect(mocks.stack.update).toHaveBeenCalledWith(stack.id, { + id: stack.id, + primaryAssetId: asset.id, }); expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { - stackId: 'stack-id', - userId: authStub.admin.user.id, + stackId: stack.id, + userId: auth.user.id, }); }); }); describe('delete', () => { it('should require stack.delete permissions', async () => { - await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(), 'stack-id')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.stack.delete).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete stack', async () => { + const auth = AuthFactory.create(); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); mocks.stack.delete.mockResolvedValue(); - await sut.delete(authStub.admin, 'stack-id'); + await sut.delete(auth, 'stack-id'); expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); expect(mocks.event.emit).toHaveBeenCalledWith('StackDelete', { stackId: 'stack-id', - userId: authStub.admin.user.id, + userId: auth.user.id, }); }); }); @@ -214,24 +232,26 @@ describe(StackService.name, () => { }); it('should fail if the assetId is the primaryAssetId', async () => { + const asset = AssetFactory.create(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: assetStub.image.id }); + mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: asset.id }); - await expect( - sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.image.id }), - ).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: asset.id })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); it("should update the asset to nullify it's stack-id", async () => { + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: assetStub.image.id }); + mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: primaryAsset.id }); - await sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.image1.id }); + await sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: asset.id }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image1.id, stackId: null }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, stackId: null }); expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { stackId: 'stack-id', userId: authStub.admin.user.id, diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 2b3e9d3f9f..09e0c10b80 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,16 +1,14 @@ import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; -import { AssetPathType, JobStatus } from 'src/enum'; +import { AssetPathType, AssetType, JobStatus } from 'src/enum'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { AlbumFactory } from 'test/factories/album.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; +import { getForStorageTemplate } from 'test/mappers'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; -const motionAsset = assetStub.storageAsset({}); -const stillAsset = assetStub.storageAsset({ livePhotoVideoId: motionAsset.id }); - describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; let mocks: ServiceMocks; @@ -110,12 +108,27 @@ describe(StorageTemplateService.name, () => { }); it('should migrate single moving picture', async () => { + const motionAsset = AssetFactory.from({ + type: AssetType.Video, + + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const stillAsset = AssetFactory.from({ + livePhotoVideoId: motionAsset.id, + + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + mocks.user.get.mockResolvedValue(userStub.user1); - const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; + const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`; const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset)); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -141,8 +154,8 @@ describe(StorageTemplateService.name, () => { }); it('should use handlebar if condition for album', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const album = AlbumFactory.from().asset().build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; @@ -150,7 +163,7 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); mocks.album.getByAssetId.mockResolvedValueOnce([album]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -166,14 +179,14 @@ describe(StorageTemplateService.name, () => { }); it('should use handlebar else condition for album', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -189,8 +202,8 @@ describe(StorageTemplateService.name, () => { }); it('should handle album startDate', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const album = AlbumFactory.from().asset().build(); const config = structuredClone(defaults); config.storageTemplate.template = @@ -199,7 +212,7 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); mocks.album.getByAssetId.mockResolvedValueOnce([album]); mocks.album.getMetadataForIds.mockResolvedValueOnce([ { @@ -225,8 +238,8 @@ describe(StorageTemplateService.name, () => { }); it('should handle else condition from album startDate', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}'; @@ -234,7 +247,7 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -248,11 +261,18 @@ describe(StorageTemplateService.name, () => { }); it('should migrate previously failed move from original path when it still exists', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); - const asset = assetStub.storageAsset(); - const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`; - const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`; + mocks.user.get.mockResolvedValue(user); + + const previousFailedNewPath = `/data/library/${user.id}/2023/Feb/${asset.originalFileName}`; + const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`; mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath)); mocks.move.getByEntity.mockResolvedValue({ @@ -262,7 +282,7 @@ describe(StorageTemplateService.name, () => { oldPath: asset.originalPath, newPath: previousFailedNewPath, }); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset)); mocks.move.update.mockResolvedValue({ id: '123', entityId: asset.id, @@ -288,9 +308,16 @@ describe(StorageTemplateService.name, () => { }); 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 () => { - mocks.user.get.mockResolvedValue(userStub.user1); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif({ fileSizeInByte: 5000 }) + .build(); + + mocks.user.get.mockResolvedValue(user); - const asset = assetStub.storageAsset({ fileSizeInByte: 5000 }); const previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`; const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; @@ -304,7 +331,7 @@ describe(StorageTemplateService.name, () => { oldPath: asset.originalPath, newPath: previousFailedNewPath, }); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset)); mocks.move.update.mockResolvedValue({ id: '123', entityId: asset.id, @@ -325,45 +352,53 @@ describe(StorageTemplateService.name, () => { }); it('should fail move if copying and hash of asset and the new file do not match', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); - const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`; + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.user.get.mockResolvedValue(user); + const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`; mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset)); mocks.move.create.mockResolvedValue({ id: '123', - entityId: testAsset.id, + entityId: asset.id, pathType: AssetPathType.Original, - oldPath: testAsset.originalPath, + oldPath: asset.originalPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.Success); - expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(testAsset.id); + expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(asset.id); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1); expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: testAsset.id, + entityId: asset.id, pathType: AssetPathType.Original, - oldPath: testAsset.originalPath, + oldPath: asset.originalPath, newPath, }); - expect(mocks.storage.rename).toHaveBeenCalledWith(testAsset.originalPath, newPath); - expect(mocks.storage.copyFile).toHaveBeenCalledWith(testAsset.originalPath, newPath); + expect(mocks.storage.rename).toHaveBeenCalledWith(asset.originalPath, newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith(asset.originalPath, newPath); expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath); expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); expect(mocks.asset.update).not.toHaveBeenCalled(); }); - const testAsset = assetStub.storageAsset(); + const testAsset = AssetFactory.from().exif({ fileSizeInByte: 12_345 }).build(); it.each` - failedPathChecksum | failedPathSize | reason - ${testAsset.checksum} | ${500} | ${'file size'} - ${Buffer.from('bad checksum', 'utf8')} | ${testAsset.fileSizeInByte} | ${'checksum'} + failedPathChecksum | failedPathSize | reason + ${testAsset.checksum} | ${500} | ${'file size'} + ${Buffer.from('bad checksum', 'utf8')} | ${testAsset.exifInfo.fileSizeInByte} | ${'checksum'} `( 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails', async ({ failedPathChecksum, failedPathSize }) => { @@ -381,7 +416,7 @@ describe(StorageTemplateService.name, () => { oldPath: testAsset.originalPath, newPath: previousFailedNewPath, }); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(testAsset)); mocks.move.update.mockResolvedValue({ id: '123', entityId: testAsset.id, @@ -414,12 +449,17 @@ describe(StorageTemplateService.name, () => { }); it('should handle an asset with a duplicate destination', async () => { - const asset = assetStub.storageAsset(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const oldPath = asset.originalPath; - const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; const newPath2 = newPath.replace('.jpg', '+1.jpg'); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ id: '123', @@ -441,9 +481,13 @@ describe(StorageTemplateService.name, () => { }); it('should skip when an asset already matches the template', async () => { - const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg' }); + const asset = AssetFactory.from({ + originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg', + }) + .exif() + .build(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); @@ -456,9 +500,13 @@ describe(StorageTemplateService.name, () => { }); it('should skip when an asset is probably a duplicate', async () => { - const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg' }); + const asset = AssetFactory.from({ + originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg', + }) + .exif() + .build(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); @@ -471,16 +519,21 @@ describe(StorageTemplateService.name, () => { }); it('should move an asset', async () => { - const asset = assetStub.storageAsset(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const oldPath = asset.originalPath; - const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`; - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.Original, - oldPath: assetStub.image.originalPath, + oldPath: asset.originalPath, newPath, }); @@ -492,9 +545,15 @@ describe(StorageTemplateService.name, () => { }); it('should use the user storage label', async () => { - const user = factory.userAdmin({ storageLabel: 'label-1' }); - const asset = assetStub.storageAsset({ ownerId: user.id }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const user = UserFactory.create({ storageLabel: 'label-1' }); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -508,7 +567,7 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - '/original/path.jpg', + asset.originalPath, expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).toHaveBeenCalledWith({ @@ -520,10 +579,16 @@ describe(StorageTemplateService.name, () => { }); it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { - const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 }); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + originalPath: '/path/to/original.jpg', + }) + .exif({ fileSizeInByte: 5000 }) + .build(); + const oldPath = asset.originalPath; - const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`; - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ @@ -561,10 +626,17 @@ describe(StorageTemplateService.name, () => { }); it('should not update the database if the move fails due to incorrect newPath filesize', async () => { - const asset = assetStub.storageAsset(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); - mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, @@ -580,22 +652,29 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - '/original/path.jpg', - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + asset.originalPath, + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.storage.copyFile).toHaveBeenCalledWith( - '/original/path.jpg', - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + asset.originalPath, + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.storage.stat).toHaveBeenCalledWith( - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should not update the database if the move fails', async () => { - const asset = assetStub.storageAsset(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.storage.rename.mockRejectedValue(new Error('Read only system')); mocks.storage.copyFile.mockRejectedValue(new Error('Read only system')); mocks.move.create.mockResolvedValue({ @@ -605,25 +684,37 @@ describe(StorageTemplateService.name, () => { oldPath: asset.originalPath, newPath: '', }); - mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([user]); await sut.handleMigration(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - '/original/path.jpg', - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + asset.originalPath, + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should migrate live photo motion video alongside the still image', async () => { - const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; + const motionAsset = AssetFactory.from({ + type: AssetType.Video, + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const stillAsset = AssetFactory.from({ + livePhotoVideoId: motionAsset.id, + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`; const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([stillAsset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -653,13 +744,17 @@ describe(StorageTemplateService.name, () => { describe('file rename correctness', () => { it('should not create double extensions when filename has lower extension', async () => { - const user = factory.userAdmin({ storageLabel: 'label-1' }); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create({ storageLabel: 'label-1' }); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, originalFileName: 'IMG_7065.HEIC', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -679,13 +774,17 @@ describe(StorageTemplateService.name, () => { }); it('should not create double extensions when filename has uppercase extension', async () => { - const user = factory.userAdmin(); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, originalFileName: 'IMG_7065.HEIC', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif({ fileSizeInByte: 12_345 }) + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -705,13 +804,17 @@ describe(StorageTemplateService.name, () => { }); it('should normalize the filename to lowercase (JPEG > jpg)', async () => { - const user = factory.userAdmin(); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, originalFileName: 'IMG_7065.JPEG', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -731,13 +834,17 @@ describe(StorageTemplateService.name, () => { }); it('should normalize the filename to lowercase (JPG > jpg)', async () => { - const user = factory.userAdmin(); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG', originalFileName: 'IMG_7065.JPG', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 71cf0d0ce8..b443d31c7f 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { join } from 'node:path'; +import { ErrorMessages } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { @@ -114,9 +115,7 @@ export class StorageService extends BaseService { this.logger.log(`Media location changed (from=${previous}, to=${current})`); if (!path.startsWith(previous)) { - throw new Error( - 'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location', - ); + throw new Error(ErrorMessages.InconsistentMediaLocation); } this.logger.warn( diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 5b50340a9f..395ff86099 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,6 +1,6 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SyncService } from 'src/services/sync.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -22,10 +22,14 @@ describe(SyncService.name, () => { describe('getAllAssetsForUserFullSync', () => { it('should return a list of all assets owned by the user', async () => { - mocks.asset.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); + const [asset1, asset2] = [ + AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(), + AssetFactory.from().owner(authStub.user1.user).build(), + ]; + mocks.asset.getAllForUserFullSync.mockResolvedValue([asset1, asset2]); await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ - mapAsset(assetStub.external, mapAssetOpts), - mapAsset(assetStub.hasEncodedVideo, mapAssetOpts), + mapAsset(asset1, mapAssetOpts), + mapAsset(asset2, mapAssetOpts), ]); expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ ownerId: authStub.user1.user.id, @@ -60,10 +64,9 @@ describe(SyncService.name, () => { }); it('should return a response requiring a full sync when there are too many changes', async () => { + const asset = AssetFactory.create(); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue( - Array.from({ length: 10_000 }).fill(assetStub.image), - ); + mocks.asset.getChangedDeltaSync.mockResolvedValue(Array.from({ length: 10_000 }).fill(asset)); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); @@ -72,15 +75,17 @@ describe(SyncService.name, () => { }); it('should return a response with changes and deletions', async () => { + const asset = AssetFactory.create({ ownerId: authStub.user1.user.id }); + const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true }); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); - mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); + mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]); + mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: false, - upserted: [mapAsset(assetStub.image1, mapAssetOpts)], - deleted: [assetStub.external.id], + upserted: [mapAsset(asset, mapAssetOpts)], + deleted: [deletedAsset.id], }); expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1); diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index f42f40940d..6fc472bb87 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -4,7 +4,6 @@ import { JobStatus } from 'src/enum'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TagService.name, () => { @@ -192,10 +191,7 @@ describe(TagService.name, () => { it('should upsert records', async () => { mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })], - }); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] }); mocks.tag.upsertAssetIds.mockResolvedValue([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, @@ -246,10 +242,7 @@ describe(TagService.name, () => { mocks.tag.get.mockResolvedValue(tagStub.tag); mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); mocks.tag.addAssetIds.mockResolvedValue(); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - tags: [factory.tag({ value: 'tag-1' })], - }); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [{ value: 'tag-1' }] }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( @@ -278,6 +271,7 @@ describe(TagService.name, () => { it('should throw an error for an invalid id', async () => { mocks.tag.getAssetIds.mockResolvedValue(new Set()); mocks.tag.removeAssetIds.mockResolvedValue(); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [] }); await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'not_found' }, @@ -288,6 +282,7 @@ describe(TagService.name, () => { mocks.tag.get.mockResolvedValue(tagStub.tag); mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); mocks.tag.removeAssetIds.mockResolvedValue(); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [] }); await expect( sut.removeAssets(authStub.admin, 'tag-1', { diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 20303421c1..d34cd84ecd 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -151,10 +151,9 @@ export class TagService extends BaseService { } private async updateTags(assetId: string) { - const asset = await this.assetRepository.getById(assetId, { tags: true }); - await this.assetRepository.upsertExif( - updateLockedColumns({ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }), - { lockedPropertiesBehavior: 'append' }, - ); + const { tags } = await this.assetRepository.getForUpdateTags(assetId); + await this.assetRepository.upsertExif(updateLockedColumns({ assetId, tags: tags.map(({ value }) => value) }), { + lockedPropertiesBehavior: 'append', + }); } } diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 86bfcef734..7b26fb5eb3 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,6 +1,6 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { ViewService } from 'src/services/view.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -32,8 +32,8 @@ describe(ViewService.name, () => { 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 asset1 = AssetFactory.create({ originalPath: '/asset/path1' }); + const asset2 = AssetFactory.create({ originalPath: '/asset/path2' }); const mockAssets = [asset1, asset2]; diff --git a/server/src/sql-tools/comparers/column.comparer.spec.ts b/server/src/sql-tools/comparers/column.comparer.spec.ts index 0fd4ed74b5..ef2afb348a 100644 --- a/server/src/sql-tools/comparers/column.comparer.spec.ts +++ b/server/src/sql-tools/comparers/column.comparer.spec.ts @@ -15,7 +15,7 @@ const testColumn: DatabaseColumn = { describe('compareColumns', () => { describe('onExtra', () => { it('should work', () => { - expect(compareColumns.onExtra(testColumn)).toEqual([ + expect(compareColumns().onExtra(testColumn)).toEqual([ { tableName: 'table1', columnName: 'test', @@ -28,7 +28,7 @@ describe('compareColumns', () => { describe('onMissing', () => { it('should work', () => { - expect(compareColumns.onMissing(testColumn)).toEqual([ + expect(compareColumns().onMissing(testColumn)).toEqual([ { type: 'ColumnAdd', column: testColumn, @@ -40,14 +40,14 @@ describe('compareColumns', () => { describe('onCompare', () => { it('should work', () => { - expect(compareColumns.onCompare(testColumn, testColumn)).toEqual([]); + expect(compareColumns().onCompare(testColumn, testColumn)).toEqual([]); }); it('should detect a change in type', () => { const source: DatabaseColumn = { ...testColumn }; const target: DatabaseColumn = { ...testColumn, type: 'text' }; const reason = 'column type is different (character varying vs text)'; - expect(compareColumns.onCompare(source, target)).toEqual([ + expect(compareColumns().onCompare(source, target)).toEqual([ { columnName: 'test', tableName: 'table1', @@ -66,7 +66,7 @@ describe('compareColumns', () => { const source: DatabaseColumn = { ...testColumn, nullable: true }; const target: DatabaseColumn = { ...testColumn, nullable: true, default: "''" }; const reason = `default is different (null vs '')`; - expect(compareColumns.onCompare(source, target)).toEqual([ + expect(compareColumns().onCompare(source, target)).toEqual([ { columnName: 'test', tableName: 'table1', @@ -83,7 +83,7 @@ describe('compareColumns', () => { const source: DatabaseColumn = { ...testColumn, comment: 'new comment' }; const target: DatabaseColumn = { ...testColumn, comment: 'old comment' }; const reason = 'comment is different (new comment vs old comment)'; - expect(compareColumns.onCompare(source, target)).toEqual([ + expect(compareColumns().onCompare(source, target)).toEqual([ { columnName: 'test', tableName: 'table1', diff --git a/server/src/sql-tools/comparers/column.comparer.ts b/server/src/sql-tools/comparers/column.comparer.ts index d3033430ef..54ffb34ffa 100644 --- a/server/src/sql-tools/comparers/column.comparer.ts +++ b/server/src/sql-tools/comparers/column.comparer.ts @@ -1,98 +1,99 @@ import { asRenameKey, getColumnType, isDefaultEqual } from 'src/sql-tools/helpers'; import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types'; -export const compareColumns = { - getRenameKey: (column) => { - return asRenameKey([ - column.tableName, - column.type, - column.nullable, - column.default, - column.storage, - column.primary, - column.isArray, - column.length, - column.identity, - column.enumName, - column.numericPrecision, - column.numericScale, - ]); - }, - onRename: (source, target) => [ - { - type: 'ColumnRename', - tableName: source.tableName, - oldName: target.name, - newName: source.name, - reason: Reason.Rename, +export const compareColumns = () => + ({ + getRenameKey: (column) => { + return asRenameKey([ + column.tableName, + column.type, + column.nullable, + column.default, + column.storage, + column.primary, + column.isArray, + column.length, + column.identity, + column.enumName, + column.numericPrecision, + column.numericScale, + ]); }, - ], - onMissing: (source) => [ - { - type: 'ColumnAdd', - column: source, - reason: Reason.MissingInTarget, + onRename: (source, target) => [ + { + type: 'ColumnRename', + tableName: source.tableName, + oldName: target.name, + newName: source.name, + reason: Reason.Rename, + }, + ], + onMissing: (source) => [ + { + type: 'ColumnAdd', + column: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'ColumnDrop', + tableName: target.tableName, + columnName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + const sourceType = getColumnType(source); + const targetType = getColumnType(target); + + const isTypeChanged = sourceType !== targetType; + + if (isTypeChanged) { + // TODO: convert between types via UPDATE when possible + return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); + } + + const items: SchemaDiff[] = []; + if (source.nullable !== target.nullable) { + items.push({ + type: 'ColumnAlter', + tableName: source.tableName, + columnName: source.name, + changes: { + nullable: source.nullable, + }, + reason: `nullable is different (${source.nullable} vs ${target.nullable})`, + }); + } + + if (!isDefaultEqual(source, target)) { + items.push({ + type: 'ColumnAlter', + tableName: source.tableName, + columnName: source.name, + changes: { + default: String(source.default ?? 'NULL'), + }, + reason: `default is different (${source.default ?? 'null'} vs ${target.default})`, + }); + } + + if (source.comment !== target.comment) { + items.push({ + type: 'ColumnAlter', + tableName: source.tableName, + columnName: source.name, + changes: { + comment: String(source.comment), + }, + reason: `comment is different (${source.comment} vs ${target.comment})`, + }); + } + + return items; }, - ], - onExtra: (target) => [ - { - type: 'ColumnDrop', - tableName: target.tableName, - columnName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - const sourceType = getColumnType(source); - const targetType = getColumnType(target); - - const isTypeChanged = sourceType !== targetType; - - if (isTypeChanged) { - // TODO: convert between types via UPDATE when possible - return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); - } - - const items: SchemaDiff[] = []; - if (source.nullable !== target.nullable) { - items.push({ - type: 'ColumnAlter', - tableName: source.tableName, - columnName: source.name, - changes: { - nullable: source.nullable, - }, - reason: `nullable is different (${source.nullable} vs ${target.nullable})`, - }); - } - - if (!isDefaultEqual(source, target)) { - items.push({ - type: 'ColumnAlter', - tableName: source.tableName, - columnName: source.name, - changes: { - default: String(source.default ?? 'NULL'), - }, - reason: `default is different (${source.default ?? 'null'} vs ${target.default})`, - }); - } - - if (source.comment !== target.comment) { - items.push({ - type: 'ColumnAlter', - tableName: source.tableName, - columnName: source.name, - changes: { - comment: String(source.comment), - }, - reason: `comment is different (${source.comment} vs ${target.comment})`, - }); - } - - return items; - }, -} satisfies Comparer; + }) satisfies Comparer; const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { return [ diff --git a/server/src/sql-tools/comparers/constraint.comparer.spec.ts b/server/src/sql-tools/comparers/constraint.comparer.spec.ts index b5da19e8df..216728f8c4 100644 --- a/server/src/sql-tools/comparers/constraint.comparer.spec.ts +++ b/server/src/sql-tools/comparers/constraint.comparer.spec.ts @@ -13,7 +13,7 @@ const testConstraint: DatabaseConstraint = { describe('compareConstraints', () => { describe('onExtra', () => { it('should work', () => { - expect(compareConstraints.onExtra(testConstraint)).toEqual([ + expect(compareConstraints().onExtra(testConstraint)).toEqual([ { type: 'ConstraintDrop', constraintName: 'test', @@ -26,7 +26,7 @@ describe('compareConstraints', () => { describe('onMissing', () => { it('should work', () => { - expect(compareConstraints.onMissing(testConstraint)).toEqual([ + expect(compareConstraints().onMissing(testConstraint)).toEqual([ { type: 'ConstraintAdd', constraint: testConstraint, @@ -38,14 +38,14 @@ describe('compareConstraints', () => { describe('onCompare', () => { it('should work', () => { - expect(compareConstraints.onCompare(testConstraint, testConstraint)).toEqual([]); + expect(compareConstraints().onCompare(testConstraint, testConstraint)).toEqual([]); }); it('should detect a change in type', () => { const source: DatabaseConstraint = { ...testConstraint }; const target: DatabaseConstraint = { ...testConstraint, columnNames: ['column1', 'column2'] }; const reason = 'Primary key columns are different: (column1 vs column1,column2)'; - expect(compareConstraints.onCompare(source, target)).toEqual([ + expect(compareConstraints().onCompare(source, target)).toEqual([ { constraintName: 'test', tableName: 'table1', diff --git a/server/src/sql-tools/comparers/constraint.comparer.ts b/server/src/sql-tools/comparers/constraint.comparer.ts index dda184039f..03128878d5 100644 --- a/server/src/sql-tools/comparers/constraint.comparer.ts +++ b/server/src/sql-tools/comparers/constraint.comparer.ts @@ -12,7 +12,7 @@ import { SchemaDiff, } from 'src/sql-tools/types'; -export const compareConstraints: Comparer = { +export const compareConstraints = (): Comparer => ({ getRenameKey: (constraint) => { switch (constraint.type) { case ConstraintType.PRIMARY_KEY: @@ -83,7 +83,7 @@ export const compareConstraints: Comparer = { } } }, -}; +}); const comparePrimaryKeyConstraint: CompareFunction = (source, target) => { if (!haveEqualColumns(source.columnNames, target.columnNames)) { diff --git a/server/src/sql-tools/comparers/enum.comparer.spec.ts b/server/src/sql-tools/comparers/enum.comparer.spec.ts index 82fc205662..d788c7cd71 100644 --- a/server/src/sql-tools/comparers/enum.comparer.spec.ts +++ b/server/src/sql-tools/comparers/enum.comparer.spec.ts @@ -7,7 +7,7 @@ const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchroni describe('compareEnums', () => { describe('onExtra', () => { it('should work', () => { - expect(compareEnums.onExtra(testEnum)).toEqual([ + expect(compareEnums().onExtra(testEnum)).toEqual([ { enumName: 'test', type: 'EnumDrop', @@ -19,7 +19,7 @@ describe('compareEnums', () => { describe('onMissing', () => { it('should work', () => { - expect(compareEnums.onMissing(testEnum)).toEqual([ + expect(compareEnums().onMissing(testEnum)).toEqual([ { type: 'EnumCreate', enum: testEnum, @@ -31,13 +31,13 @@ describe('compareEnums', () => { describe('onCompare', () => { it('should work', () => { - expect(compareEnums.onCompare(testEnum, testEnum)).toEqual([]); + expect(compareEnums().onCompare(testEnum, testEnum)).toEqual([]); }); it('should drop and recreate when values list is different', () => { const source = { name: 'test', values: ['foo', 'bar'], synchronize: true }; const target = { name: 'test', values: ['foo', 'bar', 'world'], synchronize: true }; - expect(compareEnums.onCompare(source, target)).toEqual([ + expect(compareEnums().onCompare(source, target)).toEqual([ { enumName: 'test', type: 'EnumDrop', diff --git a/server/src/sql-tools/comparers/enum.comparer.ts b/server/src/sql-tools/comparers/enum.comparer.ts index d81f9ed3c0..efc08ae727 100644 --- a/server/src/sql-tools/comparers/enum.comparer.ts +++ b/server/src/sql-tools/comparers/enum.comparer.ts @@ -1,6 +1,6 @@ import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types'; -export const compareEnums: Comparer = { +export const compareEnums = (): Comparer => ({ onMissing: (source) => [ { type: 'EnumCreate', @@ -35,4 +35,4 @@ export const compareEnums: Comparer = { return []; }, -}; +}); diff --git a/server/src/sql-tools/comparers/extension.comparer.spec.ts b/server/src/sql-tools/comparers/extension.comparer.spec.ts index 38e553719d..df70ccc761 100644 --- a/server/src/sql-tools/comparers/extension.comparer.spec.ts +++ b/server/src/sql-tools/comparers/extension.comparer.spec.ts @@ -7,7 +7,7 @@ const testExtension = { name: 'test', synchronize: true }; describe('compareExtensions', () => { describe('onExtra', () => { it('should work', () => { - expect(compareExtensions.onExtra(testExtension)).toEqual([ + expect(compareExtensions().onExtra(testExtension)).toEqual([ { extensionName: 'test', type: 'ExtensionDrop', @@ -19,7 +19,7 @@ describe('compareExtensions', () => { describe('onMissing', () => { it('should work', () => { - expect(compareExtensions.onMissing(testExtension)).toEqual([ + expect(compareExtensions().onMissing(testExtension)).toEqual([ { type: 'ExtensionCreate', extension: testExtension, @@ -31,7 +31,7 @@ describe('compareExtensions', () => { describe('onCompare', () => { it('should work', () => { - expect(compareExtensions.onCompare(testExtension, testExtension)).toEqual([]); + expect(compareExtensions().onCompare(testExtension, testExtension)).toEqual([]); }); }); }); diff --git a/server/src/sql-tools/comparers/extension.comparer.ts b/server/src/sql-tools/comparers/extension.comparer.ts index 441b00e3e3..3cb70dadc4 100644 --- a/server/src/sql-tools/comparers/extension.comparer.ts +++ b/server/src/sql-tools/comparers/extension.comparer.ts @@ -1,6 +1,6 @@ import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types'; -export const compareExtensions: Comparer = { +export const compareExtensions = (): Comparer => ({ onMissing: (source) => [ { type: 'ExtensionCreate', @@ -19,4 +19,4 @@ export const compareExtensions: Comparer = { // if the name matches they are the same return []; }, -}; +}); diff --git a/server/src/sql-tools/comparers/function.comparer.spec.ts b/server/src/sql-tools/comparers/function.comparer.spec.ts index 964768cf98..3d18aaf50a 100644 --- a/server/src/sql-tools/comparers/function.comparer.spec.ts +++ b/server/src/sql-tools/comparers/function.comparer.spec.ts @@ -11,7 +11,7 @@ const testFunction: DatabaseFunction = { describe('compareFunctions', () => { describe('onExtra', () => { it('should work', () => { - expect(compareFunctions.onExtra(testFunction)).toEqual([ + expect(compareFunctions().onExtra(testFunction)).toEqual([ { functionName: 'test', type: 'FunctionDrop', @@ -23,7 +23,7 @@ describe('compareFunctions', () => { describe('onMissing', () => { it('should work', () => { - expect(compareFunctions.onMissing(testFunction)).toEqual([ + expect(compareFunctions().onMissing(testFunction)).toEqual([ { type: 'FunctionCreate', function: testFunction, @@ -35,13 +35,13 @@ describe('compareFunctions', () => { describe('onCompare', () => { it('should ignore functions with the same hash', () => { - expect(compareFunctions.onCompare(testFunction, testFunction)).toEqual([]); + expect(compareFunctions().onCompare(testFunction, testFunction)).toEqual([]); }); it('should report differences if functions have different hashes', () => { const source: DatabaseFunction = { ...testFunction, expression: 'SELECT 1' }; const target: DatabaseFunction = { ...testFunction, expression: 'SELECT 2' }; - expect(compareFunctions.onCompare(source, target)).toEqual([ + expect(compareFunctions().onCompare(source, target)).toEqual([ { type: 'FunctionCreate', reason: 'function expression has changed (SELECT 1 vs SELECT 2)', diff --git a/server/src/sql-tools/comparers/function.comparer.ts b/server/src/sql-tools/comparers/function.comparer.ts index 000cf07058..c6217ee708 100644 --- a/server/src/sql-tools/comparers/function.comparer.ts +++ b/server/src/sql-tools/comparers/function.comparer.ts @@ -1,6 +1,6 @@ import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types'; -export const compareFunctions: Comparer = { +export const compareFunctions = (): Comparer => ({ onMissing: (source) => [ { type: 'FunctionCreate', @@ -29,4 +29,4 @@ export const compareFunctions: Comparer = { return []; }, -}; +}); diff --git a/server/src/sql-tools/comparers/index.comparer.spec.ts b/server/src/sql-tools/comparers/index.comparer.spec.ts index b00be386e0..9ae7f34f04 100644 --- a/server/src/sql-tools/comparers/index.comparer.spec.ts +++ b/server/src/sql-tools/comparers/index.comparer.spec.ts @@ -13,7 +13,7 @@ const testIndex: DatabaseIndex = { describe('compareIndexes', () => { describe('onExtra', () => { it('should work', () => { - expect(compareIndexes.onExtra(testIndex)).toEqual([ + expect(compareIndexes().onExtra(testIndex)).toEqual([ { type: 'IndexDrop', indexName: 'test', @@ -25,7 +25,7 @@ describe('compareIndexes', () => { describe('onMissing', () => { it('should work', () => { - expect(compareIndexes.onMissing(testIndex)).toEqual([ + expect(compareIndexes().onMissing(testIndex)).toEqual([ { type: 'IndexCreate', index: testIndex, @@ -37,7 +37,7 @@ describe('compareIndexes', () => { describe('onCompare', () => { it('should work', () => { - expect(compareIndexes.onCompare(testIndex, testIndex)).toEqual([]); + expect(compareIndexes().onCompare(testIndex, testIndex)).toEqual([]); }); it('should drop and recreate when column list is different', () => { @@ -55,7 +55,7 @@ describe('compareIndexes', () => { unique: true, synchronize: true, }; - expect(compareIndexes.onCompare(source, target)).toEqual([ + expect(compareIndexes().onCompare(source, target)).toEqual([ { indexName: 'test', type: 'IndexDrop', diff --git a/server/src/sql-tools/comparers/index.comparer.ts b/server/src/sql-tools/comparers/index.comparer.ts index a3db9a61e0..e474302c6e 100644 --- a/server/src/sql-tools/comparers/index.comparer.ts +++ b/server/src/sql-tools/comparers/index.comparer.ts @@ -1,7 +1,7 @@ import { asRenameKey, haveEqualColumns } from 'src/sql-tools/helpers'; import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types'; -export const compareIndexes: Comparer = { +export const compareIndexes = (): Comparer => ({ getRenameKey: (index) => { if (index.override) { return index.override.value.sql.replace(index.name, 'INDEX_NAME'); @@ -59,4 +59,4 @@ export const compareIndexes: Comparer = { return []; }, -}; +}); diff --git a/server/src/sql-tools/comparers/override.comparer.spec.ts b/server/src/sql-tools/comparers/override.comparer.spec.ts index 22093381ff..dfa6fa4455 100644 --- a/server/src/sql-tools/comparers/override.comparer.spec.ts +++ b/server/src/sql-tools/comparers/override.comparer.spec.ts @@ -11,7 +11,7 @@ const testOverride: DatabaseOverride = { describe('compareOverrides', () => { describe('onExtra', () => { it('should work', () => { - expect(compareOverrides.onExtra(testOverride)).toEqual([ + expect(compareOverrides().onExtra(testOverride)).toEqual([ { type: 'OverrideDrop', overrideName: 'test', @@ -23,7 +23,7 @@ describe('compareOverrides', () => { describe('onMissing', () => { it('should work', () => { - expect(compareOverrides.onMissing(testOverride)).toEqual([ + expect(compareOverrides().onMissing(testOverride)).toEqual([ { type: 'OverrideCreate', override: testOverride, @@ -35,7 +35,7 @@ describe('compareOverrides', () => { describe('onCompare', () => { it('should work', () => { - expect(compareOverrides.onCompare(testOverride, testOverride)).toEqual([]); + expect(compareOverrides().onCompare(testOverride, testOverride)).toEqual([]); }); it('should drop and recreate when the value changes', () => { @@ -57,7 +57,7 @@ describe('compareOverrides', () => { }, synchronize: true, }; - expect(compareOverrides.onCompare(source, target)).toEqual([ + expect(compareOverrides().onCompare(source, target)).toEqual([ { override: source, type: 'OverrideUpdate', diff --git a/server/src/sql-tools/comparers/override.comparer.ts b/server/src/sql-tools/comparers/override.comparer.ts index 369f7cd59f..999770bf69 100644 --- a/server/src/sql-tools/comparers/override.comparer.ts +++ b/server/src/sql-tools/comparers/override.comparer.ts @@ -1,6 +1,6 @@ import { Comparer, DatabaseOverride, Reason } from 'src/sql-tools/types'; -export const compareOverrides: Comparer = { +export const compareOverrides = (): Comparer => ({ onMissing: (source) => [ { type: 'OverrideCreate', @@ -26,4 +26,4 @@ export const compareOverrides: Comparer = { return []; }, -}; +}); diff --git a/server/src/sql-tools/comparers/parameter.comparer.spec.ts b/server/src/sql-tools/comparers/parameter.comparer.spec.ts index cd1520faff..23e6c78118 100644 --- a/server/src/sql-tools/comparers/parameter.comparer.spec.ts +++ b/server/src/sql-tools/comparers/parameter.comparer.spec.ts @@ -13,7 +13,7 @@ const testParameter: DatabaseParameter = { describe('compareParameters', () => { describe('onExtra', () => { it('should work', () => { - expect(compareParameters.onExtra(testParameter)).toEqual([ + expect(compareParameters().onExtra(testParameter)).toEqual([ { type: 'ParameterReset', databaseName: 'immich', @@ -26,7 +26,7 @@ describe('compareParameters', () => { describe('onMissing', () => { it('should work', () => { - expect(compareParameters.onMissing(testParameter)).toEqual([ + expect(compareParameters().onMissing(testParameter)).toEqual([ { type: 'ParameterSet', parameter: testParameter, @@ -38,7 +38,7 @@ describe('compareParameters', () => { describe('onCompare', () => { it('should work', () => { - expect(compareParameters.onCompare(testParameter, testParameter)).toEqual([]); + expect(compareParameters().onCompare(testParameter, testParameter)).toEqual([]); }); }); }); diff --git a/server/src/sql-tools/comparers/parameter.comparer.ts b/server/src/sql-tools/comparers/parameter.comparer.ts index d1a33ad090..41d0508d70 100644 --- a/server/src/sql-tools/comparers/parameter.comparer.ts +++ b/server/src/sql-tools/comparers/parameter.comparer.ts @@ -1,6 +1,6 @@ import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types'; -export const compareParameters: Comparer = { +export const compareParameters = (): Comparer => ({ onMissing: (source) => [ { type: 'ParameterSet', @@ -20,4 +20,4 @@ export const compareParameters: Comparer = { // TODO return []; }, -}; +}); diff --git a/server/src/sql-tools/comparers/table.comparer.spec.ts b/server/src/sql-tools/comparers/table.comparer.spec.ts index 575e25ab44..909db26ea9 100644 --- a/server/src/sql-tools/comparers/table.comparer.spec.ts +++ b/server/src/sql-tools/comparers/table.comparer.spec.ts @@ -14,7 +14,7 @@ const testTable: DatabaseTable = { describe('compareParameters', () => { describe('onExtra', () => { it('should work', () => { - expect(compareTables.onExtra(testTable)).toEqual([ + expect(compareTables({}).onExtra(testTable)).toEqual([ { type: 'TableDrop', tableName: 'test', @@ -26,7 +26,7 @@ describe('compareParameters', () => { describe('onMissing', () => { it('should work', () => { - expect(compareTables.onMissing(testTable)).toEqual([ + expect(compareTables({}).onMissing(testTable)).toEqual([ { type: 'TableCreate', table: testTable, @@ -38,7 +38,7 @@ describe('compareParameters', () => { describe('onCompare', () => { it('should work', () => { - expect(compareTables.onCompare(testTable, testTable)).toEqual([]); + expect(compareTables({}).onCompare(testTable, testTable)).toEqual([]); }); }); }); diff --git a/server/src/sql-tools/comparers/table.comparer.ts b/server/src/sql-tools/comparers/table.comparer.ts index 0b36b7fce4..6576dce1b1 100644 --- a/server/src/sql-tools/comparers/table.comparer.ts +++ b/server/src/sql-tools/comparers/table.comparer.ts @@ -3,9 +3,9 @@ import { compareConstraints } from 'src/sql-tools/comparers/constraint.comparer' import { compareIndexes } from 'src/sql-tools/comparers/index.comparer'; import { compareTriggers } from 'src/sql-tools/comparers/trigger.comparer'; import { compare } from 'src/sql-tools/helpers'; -import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types'; +import { Comparer, DatabaseTable, Reason, SchemaDiffOptions } from 'src/sql-tools/types'; -export const compareTables: Comparer = { +export const compareTables = (options: SchemaDiffOptions): Comparer => ({ onMissing: (source) => [ { type: 'TableCreate', @@ -20,14 +20,12 @@ export const compareTables: Comparer = { reason: Reason.MissingInSource, }, ], - onCompare: (source, target) => compareTable(source, target), -}; - -const compareTable = (source: DatabaseTable, target: DatabaseTable): SchemaDiff[] => { - return [ - ...compare(source.columns, target.columns, {}, compareColumns), - ...compare(source.indexes, target.indexes, {}, compareIndexes), - ...compare(source.constraints, target.constraints, {}, compareConstraints), - ...compare(source.triggers, target.triggers, {}, compareTriggers), - ]; -}; + onCompare: (source, target) => { + return [ + ...compare(source.columns, target.columns, options.columns, compareColumns()), + ...compare(source.indexes, target.indexes, options.indexes, compareIndexes()), + ...compare(source.constraints, target.constraints, options.constraints, compareConstraints()), + ...compare(source.triggers, target.triggers, options.triggers, compareTriggers()), + ]; + }, +}); diff --git a/server/src/sql-tools/comparers/trigger.comparer.spec.ts b/server/src/sql-tools/comparers/trigger.comparer.spec.ts index 731fae8da2..c80b0d2273 100644 --- a/server/src/sql-tools/comparers/trigger.comparer.spec.ts +++ b/server/src/sql-tools/comparers/trigger.comparer.spec.ts @@ -15,7 +15,7 @@ const testTrigger: DatabaseTrigger = { describe('compareTriggers', () => { describe('onExtra', () => { it('should work', () => { - expect(compareTriggers.onExtra(testTrigger)).toEqual([ + expect(compareTriggers().onExtra(testTrigger)).toEqual([ { type: 'TriggerDrop', tableName: 'table1', @@ -28,7 +28,7 @@ describe('compareTriggers', () => { describe('onMissing', () => { it('should work', () => { - expect(compareTriggers.onMissing(testTrigger)).toEqual([ + expect(compareTriggers().onMissing(testTrigger)).toEqual([ { type: 'TriggerCreate', trigger: testTrigger, @@ -40,49 +40,49 @@ describe('compareTriggers', () => { describe('onCompare', () => { it('should work', () => { - expect(compareTriggers.onCompare(testTrigger, testTrigger)).toEqual([]); + expect(compareTriggers().onCompare(testTrigger, testTrigger)).toEqual([]); }); it('should detect a change in function name', () => { const source: DatabaseTrigger = { ...testTrigger, functionName: 'my_new_name' }; const target: DatabaseTrigger = { ...testTrigger, functionName: 'my_old_name' }; const reason = `function is different (my_new_name vs my_old_name)`; - expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); + expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); }); it('should detect a change in actions', () => { const source: DatabaseTrigger = { ...testTrigger, actions: ['delete'] }; const target: DatabaseTrigger = { ...testTrigger, actions: ['delete', 'insert'] }; const reason = `action is different (delete vs delete,insert)`; - expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); + expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); }); it('should detect a change in timing', () => { const source: DatabaseTrigger = { ...testTrigger, timing: 'before' }; const target: DatabaseTrigger = { ...testTrigger, timing: 'after' }; const reason = `timing method is different (before vs after)`; - expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); + expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); }); it('should detect a change in scope', () => { const source: DatabaseTrigger = { ...testTrigger, scope: 'row' }; const target: DatabaseTrigger = { ...testTrigger, scope: 'statement' }; const reason = `scope is different (row vs statement)`; - expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); + expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); }); it('should detect a change in new table reference', () => { const source: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: 'new_table' }; const target: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: undefined }; const reason = `new table reference is different (new_table vs undefined)`; - expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); + expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); }); it('should detect a change in old table reference', () => { const source: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: 'old_table' }; const target: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: undefined }; const reason = `old table reference is different (old_table vs undefined)`; - expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); + expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); }); }); }); diff --git a/server/src/sql-tools/comparers/trigger.comparer.ts b/server/src/sql-tools/comparers/trigger.comparer.ts index da1de6e48b..4ba2d5dba3 100644 --- a/server/src/sql-tools/comparers/trigger.comparer.ts +++ b/server/src/sql-tools/comparers/trigger.comparer.ts @@ -1,6 +1,6 @@ import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types'; -export const compareTriggers: Comparer = { +export const compareTriggers = (): Comparer => ({ onMissing: (source) => [ { type: 'TriggerCreate', @@ -38,4 +38,4 @@ export const compareTriggers: Comparer = { return []; }, -}; +}); diff --git a/server/src/sql-tools/schema-diff.ts b/server/src/sql-tools/schema-diff.ts index bca58c3228..846210931b 100644 --- a/server/src/sql-tools/schema-diff.ts +++ b/server/src/sql-tools/schema-diff.ts @@ -20,12 +20,12 @@ import { */ export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, options: SchemaDiffOptions = {}) => { const items = [ - ...compare(source.parameters, target.parameters, options.parameters, compareParameters), - ...compare(source.extensions, target.extensions, options.extensions, compareExtensions), - ...compare(source.functions, target.functions, options.functions, compareFunctions), - ...compare(source.enums, target.enums, options.enums, compareEnums), - ...compare(source.tables, target.tables, options.tables, compareTables), - ...compare(source.overrides, target.overrides, options.overrides, compareOverrides), + ...compare(source.parameters, target.parameters, options.parameters, compareParameters()), + ...compare(source.extensions, target.extensions, options.extensions, compareExtensions()), + ...compare(source.functions, target.functions, options.functions, compareFunctions()), + ...compare(source.enums, target.enums, options.enums, compareEnums()), + ...compare(source.tables, target.tables, options.tables, compareTables(options)), + ...compare(source.overrides, target.overrides, options.overrides, compareOverrides()), ]; type SchemaName = SchemaDiff['type']; @@ -103,6 +103,7 @@ export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, optio return { items: orderedItems, asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options), + asHuman: () => schemaDiffToHuman(orderedItems), }; }; @@ -113,7 +114,14 @@ export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOpt return items.flatMap((item) => asSql(item, options)); }; -const asSql = (item: SchemaDiff, options: SchemaDiffToSqlOptions): string[] => { +/** + * Convert schema diff into human readable statements + */ +export const schemaDiffToHuman = (items: SchemaDiff[]): string[] => { + return items.flatMap((item) => asHuman(item)); +}; + +export const asSql = (item: SchemaDiff, options: SchemaDiffToSqlOptions): string[] => { const ctx = new BaseContext(options); for (const transform of transformers) { const result = transform(ctx, item); @@ -127,6 +135,88 @@ const asSql = (item: SchemaDiff, options: SchemaDiffToSqlOptions): string[] => { throw new Error(`Unhandled schema diff type: ${item.type}`); }; +export const asHuman = (item: SchemaDiff): string => { + switch (item.type) { + case 'ExtensionCreate': { + return `The extension "${item.extension.name}" is missing and needs to be created`; + } + case 'ExtensionDrop': { + return `The extension "${item.extensionName}" exists but is no longer needed`; + } + case 'FunctionCreate': { + return `The function "${item.function.name}" is missing and needs to be created`; + } + case 'FunctionDrop': { + return `The function "${item.functionName}" exists but should be removed`; + } + case 'TableCreate': { + return `The table "${item.table.name}" is missing and needs to be created`; + } + case 'TableDrop': { + return `The table "${item.tableName}" exists but should be removed`; + } + case 'ColumnAdd': { + return `The column "${item.column.tableName}"."${item.column.name}" is missing and needs to be created`; + } + case 'ColumnRename': { + return `The column "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`; + } + case 'ColumnAlter': { + return `The column "${item.tableName}"."${item.columnName}" has changes that need to be applied ${JSON.stringify( + item.changes, + )}`; + } + case 'ColumnDrop': { + return `The column "${item.tableName}"."${item.columnName}" exists but should be removed`; + } + case 'ConstraintAdd': { + return `The constraint "${item.constraint.tableName}"."${item.constraint.name}" (${item.constraint.type}) is missing and needs to be created`; + } + case 'ConstraintRename': { + return `The constraint "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`; + } + case 'ConstraintDrop': { + return `The constraint "${item.tableName}"."${item.constraintName}" exists but should be removed`; + } + case 'IndexCreate': { + return `The index "${item.index.tableName}"."${item.index.name}" is missing and needs to be created`; + } + case 'IndexRename': { + return `The index "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`; + } + case 'IndexDrop': { + return `The index "${item.indexName}" exists but is no longer needed`; + } + case 'TriggerCreate': { + return `The trigger "${item.trigger.tableName}"."${item.trigger.name}" is missing and needs to be created`; + } + case 'TriggerDrop': { + return `The trigger "${item.tableName}"."${item.triggerName}" exists but is no longer needed`; + } + case 'ParameterSet': { + return `The configuration parameter "${item.parameter.name}" has a different value and needs to be updated to "${item.parameter.value}"`; + } + case 'ParameterReset': { + return `The configuration parameter "${item.parameterName}" is set, but should be reset to the default value`; + } + case 'EnumCreate': { + return `The enum "${item.enum.name}" is missing and needs to be created`; + } + case 'EnumDrop': { + return `The enum "${item.enumName}" exists but is no longer needed`; + } + case 'OverrideCreate': { + return `The override "${item.override.name}" is missing and needs to be created`; + } + case 'OverrideUpdate': { + return `The override "${item.override.name}" needs to be updated`; + } + case 'OverrideDrop': { + return `The override "${item.overrideName}" exists but is no longer needed`; + } + } +}; + const withComments = (comments: boolean | undefined, item: SchemaDiff): string => { if (!comments) { return ''; diff --git a/server/src/sql-tools/schema-from-database.ts b/server/src/sql-tools/schema-from-database.ts index b7b76a68b1..ee34e9dd8d 100644 --- a/server/src/sql-tools/schema-from-database.ts +++ b/server/src/sql-tools/schema-from-database.ts @@ -5,14 +5,20 @@ import { ReaderContext } from 'src/sql-tools/contexts/reader-context'; import { readers } from 'src/sql-tools/readers'; import { DatabaseSchema, PostgresDB, SchemaFromDatabaseOptions } from 'src/sql-tools/types'; +export type DatabaseLike = Sql | Kysely; + +const isKysely = (db: DatabaseLike): db is Kysely => db instanceof Kysely; + /** * Load schema from a database url */ export const schemaFromDatabase = async ( - postgres: Sql, + database: DatabaseLike, options: SchemaFromDatabaseOptions = {}, ): Promise => { - const db = new Kysely({ dialect: new PostgresJSDialect({ postgres }) }); + const db = isKysely(database) + ? (database as Kysely) + : new Kysely({ dialect: new PostgresJSDialect({ postgres: database }) }); const ctx = new ReaderContext(options); try { @@ -22,6 +28,9 @@ export const schemaFromDatabase = async ( return ctx.build(); } finally { - await db.destroy(); + // only close the connection it we created it + if (!isKysely(database)) { + await db.destroy(); + } } }; diff --git a/server/src/sql-tools/types.ts b/server/src/sql-tools/types.ts index 899ba1b963..9d93a79ff1 100644 --- a/server/src/sql-tools/types.ts +++ b/server/src/sql-tools/types.ts @@ -30,6 +30,10 @@ export type SchemaDiffToSqlOptions = BaseContextOptions & { export type SchemaDiffOptions = BaseContextOptions & { tables?: IgnoreOptions; + columns?: IgnoreOptions; + indexes?: IgnoreOptions; + triggers?: IgnoreOptions; + constraints?: IgnoreOptions; functions?: IgnoreOptions; enums?: IgnoreOptions; extensions?: IgnoreOptions; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f8fb3d215d..c5d1476f65 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,10 +1,9 @@ import { BadRequestException } from '@nestjs/common'; import { StorageCore } from 'src/cores/storage.core'; -import { AssetFile, Exif } from 'src/database'; +import { AssetFile } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ExifResponseDto } from 'src/dtos/exif.dto'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -210,20 +209,26 @@ const isFlipped = (orientation?: string | null) => { return value && [5, 6, 7, 8, -90, 90].includes(value); }; -export const getDimensions = (exifInfo: ExifResponseDto | Exif) => { - const { exifImageWidth: width, exifImageHeight: height } = exifInfo; - +export const getDimensions = ({ + exifImageHeight: height, + exifImageWidth: width, + orientation, +}: { + exifImageHeight: number | null; + exifImageWidth: number | null; + orientation: string | null; +}) => { if (!width || !height) { return { width: 0, height: 0 }; } - if (isFlipped(exifInfo.orientation)) { + if (isFlipped(orientation)) { return { width: height, height: width }; } return { width, height }; }; -export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => { - return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); +export const isPanorama = (asset: { projectionType: string | null; originalFileName: string }) => { + return asset.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); }; diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index c09f3a381b..b0e31afe39 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -76,6 +76,7 @@ describe('mimeTypes', () => { { mimetype: 'image/x-sony-sr2', extension: '.sr2' }, { mimetype: 'image/x-sony-srf', extension: '.srf' }, { mimetype: 'image/x3f', extension: '.x3f' }, + { mimetype: 'application/mxf', extension: '.mxf' }, { mimetype: 'video/3gpp', extension: '.3gp' }, { mimetype: 'video/3gpp', extension: '.3gpp' }, { mimetype: 'video/avi', extension: '.avi' }, @@ -188,7 +189,9 @@ describe('mimeTypes', () => { it('should contain only video mime types', () => { const values = Object.values(mimeTypes.video).flat(); - expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/'))); + expect(values).toEqual( + values.filter((mimeType) => mimeType.startsWith('video/') || mimeType === 'application/mxf'), + ); }); for (const [extension, v] of Object.entries(mimeTypes.video)) { diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index f6dca4e103..4e91bbd7f1 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -98,6 +98,7 @@ const video: Record = { '.mpeg': ['video/mpeg'], '.mpg': ['video/mpeg'], '.mts': ['video/mp2t'], + '.mxf': ['application/mxf'], '.vob': ['video/mpeg'], '.webm': ['video/webm'], '.wmv': ['video/x-ms-wmv'], @@ -141,9 +142,12 @@ export const mimeTypes = { const contentType = lookup(filename); if (contentType.startsWith('image/')) { return AssetType.Image; - } else if (contentType.startsWith('video/')) { + } + + if (contentType.startsWith('video/') || contentType === 'application/mxf') { return AssetType.Video; } + return AssetType.Other; }, getSupportedFileExtensions: () => [...Object.keys(image), ...Object.keys(video)], diff --git a/server/src/utils/tasks.ts b/server/src/utils/tasks.ts new file mode 100644 index 0000000000..4a8276fc46 --- /dev/null +++ b/server/src/utils/tasks.ts @@ -0,0 +1,13 @@ +export type Task = () => Promise | unknown; + +export class Tasks { + private tasks: Task[] = []; + + push(...tasks: Task[]) { + this.tasks.push(...tasks); + } + + async all() { + await Promise.all(this.tasks.map((item) => item())); + } +} diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts new file mode 100644 index 0000000000..b2286cad54 --- /dev/null +++ b/server/test/factories/asset-face.factory.ts @@ -0,0 +1,47 @@ +import { Selectable } from 'kysely'; +import { SourceType } from 'src/enum'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { build } from 'test/factories/builder.factory'; +import { PersonFactory } from 'test/factories/person.factory'; +import { AssetFaceLike, FactoryBuilder, PersonLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AssetFaceFactory { + #person: PersonFactory | null = null; + + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetFaceLike = {}) { + return AssetFaceFactory.from(dto).build(); + } + + static from(dto: AssetFaceLike = {}) { + return new AssetFaceFactory({ + assetId: newUuid(), + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, + deletedAt: null, + id: newUuid(), + imageHeight: 500, + imageWidth: 400, + isVisible: true, + personId: null, + sourceType: SourceType.MachineLearning, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + person(dto: PersonLike = {}, builder?: FactoryBuilder) { + this.#person = build(PersonFactory.from(dto), builder); + this.value.personId = this.#person.build().id; + return this; + } + + build() { + return { ...this.value, person: this.#person?.build() ?? null }; + } +} diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 8cbf704abf..4d54ba820b 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -1,12 +1,23 @@ import { Selectable } from 'kysely'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { StackTable } from 'src/schema/tables/stack.table'; import { AssetEditFactory } from 'test/factories/asset-edit.factory'; import { AssetExifFactory } from 'test/factories/asset-exif.factory'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { build } from 'test/factories/builder.factory'; -import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { StackFactory } from 'test/factories/stack.factory'; +import { + AssetEditLike, + AssetExifLike, + AssetFaceLike, + AssetFileLike, + AssetLike, + FactoryBuilder, + StackLike, + UserLike, +} from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory'; @@ -15,6 +26,8 @@ export class AssetFactory { #assetExif?: AssetExifFactory; #files: AssetFileFactory[] = []; #edits: AssetEditFactory[] = []; + #faces: AssetFaceFactory[] = []; + #stack?: Selectable & { assets: Selectable[]; primaryAsset: Selectable }; private constructor(private readonly value: Selectable) { value.ownerId ??= newUuid(); @@ -28,7 +41,7 @@ export class AssetFactory { static from(dto: AssetLike = {}) { const id = dto.id ?? newUuid(); - const originalFileName = dto.originalFileName ?? `IMG_${id}.jpg`; + const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`); return new AssetFactory({ id, @@ -82,6 +95,11 @@ export class AssetFactory { return this; } + face(dto: AssetFaceLike = {}, builder?: FactoryBuilder) { + this.#faces.push(build(AssetFaceFactory.from({ assetId: this.value?.id, ...dto }), builder)); + return this; + } + file(dto: AssetFileLike = {}, builder?: FactoryBuilder) { this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder)); return this; @@ -111,6 +129,12 @@ export class AssetFactory { return this; } + stack(dto: StackLike = {}, builder?: FactoryBuilder) { + this.#stack = build(StackFactory.from(dto).primaryAsset(this.value), builder).build(); + this.value.stackId = this.#stack.id; + return this; + } + build() { const exif = this.#assetExif?.build(); @@ -120,7 +144,9 @@ export class AssetFactory { exifInfo: exif as NonNullable, files: this.#files.map((file) => file.build()), edits: this.#edits.map((edit) => edit.build()), - faces: [] as Selectable[], + faces: this.#faces.map((face) => face.build()), + stack: this.#stack ?? null, + tags: [], }; } } diff --git a/server/test/factories/person.factory.ts b/server/test/factories/person.factory.ts new file mode 100644 index 0000000000..8e016e5398 --- /dev/null +++ b/server/test/factories/person.factory.ts @@ -0,0 +1,34 @@ +import { Selectable } from 'kysely'; +import { PersonTable } from 'src/schema/tables/person.table'; +import { PersonLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PersonFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: PersonLike = {}) { + return PersonFactory.from(dto).build(); + } + + static from(dto: PersonLike = {}) { + return new PersonFactory({ + birthDate: null, + color: null, + createdAt: newDate(), + faceAssetId: null, + id: newUuid(), + isFavorite: false, + isHidden: false, + name: 'person', + ownerId: newUuid(), + thumbnailPath: '/data/thumbs/person-thumbnail.jpg', + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/shared-link.factory.ts b/server/test/factories/shared-link.factory.ts index 585b43dd84..5ac5f1756b 100644 --- a/server/test/factories/shared-link.factory.ts +++ b/server/test/factories/shared-link.factory.ts @@ -2,14 +2,16 @@ import { Selectable } from 'kysely'; import { SharedLinkType } from 'src/enum'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { AlbumFactory } from 'test/factories/album.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; import { build } from 'test/factories/builder.factory'; -import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { AlbumLike, AssetLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; import { factory, newDate, newUuid } from 'test/small.factory'; export class SharedLinkFactory { #owner: UserFactory; #album?: AlbumFactory; + #assets: AssetFactory[] = []; private constructor(private readonly value: Selectable) { value.userId ??= newUuid(); @@ -52,12 +54,18 @@ export class SharedLinkFactory { return this; } + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + this.#assets.push(asset); + return this; + } + build() { return { ...this.value, owner: this.#owner.build(), - album: this.#album?.build(), - assets: [], + album: this.#album?.build() ?? null, + assets: this.#assets.map((asset) => asset.build()), }; } } diff --git a/server/test/factories/stack.factory.ts b/server/test/factories/stack.factory.ts new file mode 100644 index 0000000000..69775973c4 --- /dev/null +++ b/server/test/factories/stack.factory.ts @@ -0,0 +1,52 @@ +import { Selectable } from 'kysely'; +import { StackTable } from 'src/schema/tables/stack.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetLike, FactoryBuilder, StackLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class StackFactory { + #assets: AssetFactory[] = []; + #primaryAsset: AssetFactory; + + private constructor(private readonly value: Selectable) { + this.#primaryAsset = AssetFactory.from(); + this.value.primaryAssetId = this.#primaryAsset.build().id; + } + + static create(dto: StackLike = {}) { + return StackFactory.from(dto).build(); + } + + static from(dto: StackLike = {}) { + return new StackFactory({ + createdAt: newDate(), + id: newUuid(), + ownerId: newUuid(), + primaryAssetId: newUuid(), + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + this.#assets.push(build(AssetFactory.from(dto), builder)); + return this; + } + + primaryAsset(dto: AssetLike = {}, builder?: FactoryBuilder) { + this.#primaryAsset = build(AssetFactory.from(dto), builder); + this.value.primaryAssetId = this.#primaryAsset.build().id; + this.#assets.push(this.#primaryAsset); + return this; + } + + build() { + return { + ...this.value, + assets: this.#assets.map((asset) => asset.build()), + primaryAsset: this.#primaryAsset.build(), + }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 534e290f59..c5a327a624 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -3,9 +3,12 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { PersonTable } from 'src/schema/tables/person.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; export type FactoryBuilder = (builder: T) => R; @@ -18,3 +21,6 @@ export type AlbumLike = Partial>; export type AlbumUserLike = Partial>; export type SharedLinkLike = Partial>; export type UserLike = Partial>; +export type AssetFaceLike = Partial>; +export type PersonLike = Partial>; +export type StackLike = Partial>; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts deleted file mode 100644 index 3c89056f37..0000000000 --- a/server/test/fixtures/asset.stub.ts +++ /dev/null @@ -1,723 +0,0 @@ -import { AssetFace, AssetFile, Exif } from 'src/database'; -import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { StorageAsset } from 'src/types'; -import { authStub } from 'test/fixtures/auth.stub'; -import { fileStub } from 'test/fixtures/file.stub'; -import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; - -export const previewFile = factory.assetFile({ type: AssetFileType.Preview }); - -const thumbnailFile = factory.assetFile({ - type: AssetFileType.Thumbnail, - path: '/uploads/user-id/webp/path.ext', -}); - -const fullsizeFile = factory.assetFile({ - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/path.webp', -}); - -const files = [fullsizeFile, previewFile, thumbnailFile]; - -export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => { - return { - id: stackId, - assets, - ownerId: assets[0].ownerId, - primaryAsset: assets[0], - primaryAssetId: assets[0].id, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - updateId: expect.any(String), - }; -}; - -export const assetStub = { - storageAsset: (asset: Partial = {}) => ({ - id: 'asset-id', - ownerId: 'user-id', - livePhotoVideoId: null, - type: AssetType.Image, - isExternal: false, - checksum: Buffer.from('file hash'), - timeZone: null, - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - originalPath: '/original/path.jpg', - originalFileName: 'IMG_123.jpg', - fileSizeInByte: 12_345, - files: [], - make: 'FUJIFILM', - model: 'X-T50', - lensModel: 'XF27mm F2.8 R WR', - isEdited: false, - ...asset, - }), - 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'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/library/IMG_123.jpg', - files: [thumbnailFile], - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - 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, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - faces: [], - exifInfo: {} as Exif, - deletedAt: null, - isExternal: false, - duplicateId: null, - isOffline: false, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: 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'), - owner: userStub.admin, - ownerId: 'admin-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - files, - type: AssetType.Image, - 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, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 1000, - exifImageWidth: 1000, - } as Exif, - stackId: 'stack-1', - stack: stackStub('stack-1', [ - { id: 'primary-asset-id' } as MapAsset & { exifInfo: Exif }, - { id: 'stack-child-asset-1' } as MapAsset & { exifInfo: Exif }, - { id: 'stack-child-asset-2' } as MapAsset & { exifInfo: Exif }, - ]), - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: 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'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - 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('2025-01-01T01:02:03.456Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - stack: null, - orientation: '', - projectionType: null, - height: null, - width: null, - visibility: AssetVisibility.Timeline, - edits: [], - isEdited: false, - }), - - trashed: 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', - 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'), - 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, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - status: AssetStatus.Trashed, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - 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'), - 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'), - 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, - duration: null, - libraryId: 'library-id', - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: true, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - 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'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: 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'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('path hash', 'utf8'), - type: AssetType.Image, - 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'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - libraryId: 'library-id', - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - stackId: null, - stack: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - 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'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - 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'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - deletedAt: null, - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - isExternal: false, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - exifInfo: { - fileSizeInByte: 5000, - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - stackId: null, - libraryId: null, - stack: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: 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'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Video, - files: [previewFile], - thumbhash: 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, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - faces: [], - exifInfo: { - fileSizeInByte: 100_000, - exifImageHeight: 2160, - exifImageWidth: 3840, - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - livePhotoMotionAsset: Object.freeze({ - status: AssetStatus.Active, - id: fileStub.livePhotoMotion.uuid, - originalPath: fileStub.livePhotoMotion.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.Video, - fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - exifInfo: { - fileSizeInByte: 100_000, - timeZone: `America/New_York`, - }, - files: [], - libraryId: null, - visibility: AssetVisibility.Hidden, - width: null, - height: null, - edits: [] as AssetEditActionItem[], - isEdited: false, - } as unknown as MapAsset & { - faces: AssetFace[]; - files: (AssetFile & { isProgressive: boolean })[]; - exifInfo: Exif; - edits: AssetEditActionItem[]; - }), - - livePhotoStillAsset: Object.freeze({ - id: 'live-photo-still-asset', - status: AssetStatus.Active, - originalPath: fileStub.livePhotoStill.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.Image, - livePhotoVideoId: 'live-photo-motion-asset', - 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`, - }, - files, - faces: [] as AssetFace[], - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [] as AssetEditActionItem[], - isEdited: false, - } as unknown as MapAsset & { - faces: AssetFace[]; - files: (AssetFile & { isProgressive: boolean })[]; - edits: AssetEditActionItem[]; - }), - - livePhotoWithOriginalFileName: Object.freeze({ - id: 'live-photo-still-asset', - status: AssetStatus.Active, - originalPath: fileStub.livePhotoStill.originalPath, - originalFileName: fileStub.livePhotoStill.originalName, - ownerId: authStub.user1.user.id, - type: AssetType.Image, - livePhotoVideoId: 'live-photo-motion-asset', - 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`, - }, - files: [] as AssetFile[], - libraryId: null, - faces: [] as AssetFace[], - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [] as AssetEditActionItem[], - isEdited: false, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), - - 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'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - checksum: Buffer.from('file hash', 'utf8'), - originalPath: '/original/path.ext', - type: AssetType.Image, - files: [previewFile], - thumbhash: null, - encodedVideoPath: null, - createdAt: new Date('2023-02-22T05:06:29.716Z'), - updatedAt: new Date('2023-02-22T05:06:29.716Z'), - localDateTime: new Date('2020-12-31T23:59:00.000Z'), - isFavorite: false, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - exifInfo: { - latitude: 100, - longitude: 100, - fileSizeInByte: 23_456, - city: 'test-city', - state: 'test-state', - country: 'test-country', - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - tags: [], - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - 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'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Video, - files: [previewFile], - thumbhash: null, - encodedVideoPath: '/encoded/video/path.mp4', - 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, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - faces: [], - exifInfo: { - fileSizeInByte: 100_000, - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - stack: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: 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'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.dng', - 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'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.dng', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - profileDescription: 'Adobe RGB', - bitsPerSample: 14, - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - withCropEdit: 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', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - 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('2025-01-01T01:02:03.456Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - stack: null, - orientation: '', - projectionType: null, - height: 3840, - width: 2160, - visibility: AssetVisibility.Timeline, - edits: [ - { - action: AssetEditAction.Crop, - parameters: { - width: 1512, - height: 1152, - x: 216, - y: 1512, - }, - }, - ] as AssetEditActionItem[], - isEdited: true, - }), -}; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts deleted file mode 100644 index 94a2dcff22..0000000000 --- a/server/test/fixtures/face.stub.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { SourceType } from 'src/enum'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { personStub } from 'test/fixtures/person.stub'; - -export const faceStub = { - face1: Object.freeze({ - id: 'assetFaceId1', - assetId: assetStub.image.id, - asset: { - ...assetStub.image, - libraryId: null, - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - stackId: null, - }, - personId: personStub.withName.id, - person: personStub.withName, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, - deletedAt: new Date(), - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - primaryFace1: Object.freeze({ - id: 'assetFaceId2', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: personStub.primaryPerson.id, - person: personStub.primaryPerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - mergeFace1: Object.freeze({ - id: 'assetFaceId3', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: personStub.mergePerson.id, - person: personStub.mergePerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - noPerson1: Object.freeze({ - id: 'assetFaceId8', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: null, - person: null, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - noPerson2: Object.freeze({ - id: 'assetFaceId9', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: null, - person: null, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - 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, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - 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, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - withBirthDate: Object.freeze({ - id: 'assetFaceId10', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: personStub.withBirthDate.id, - person: personStub.withBirthDate, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), -}; diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 35a7a8ed7d..9d48fcc8f8 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -1,5 +1,5 @@ -import { AssetType } from 'src/enum'; -import { previewFile } from 'test/fixtures/asset.stub'; +import { AssetFileType, AssetType } from 'src/enum'; +import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { userStub } from 'test/fixtures/user.stub'; const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125'; @@ -179,7 +179,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), newThumbnailMiddle: Object.freeze({ ownerId: userStub.admin.id, @@ -192,7 +192,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), newThumbnailEnd: Object.freeze({ ownerId: userStub.admin.id, @@ -205,7 +205,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), rawEmbeddedThumbnail: Object.freeze({ ownerId: userStub.admin.id, @@ -218,7 +218,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.dng', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), negativeCoordinate: Object.freeze({ ownerId: userStub.admin.id, @@ -231,7 +231,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), overflowingCoordinate: Object.freeze({ ownerId: userStub.admin.id, @@ -244,7 +244,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), videoThumbnail: Object.freeze({ ownerId: userStub.admin.id, @@ -257,6 +257,6 @@ export const personThumbnailStub = { type: AssetType.Video, originalPath: '/original/path.mp4', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 859b6b6ae2..a42ff743bc 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -2,7 +2,7 @@ import { UserAdmin } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -31,7 +31,7 @@ export const sharedLinkStub = { albumId: null, album: null, description: null, - assets: [assetStub.image], + assets: [AssetFactory.create()], password: 'password', slug: null, }), diff --git a/server/test/mappers.ts b/server/test/mappers.ts new file mode 100644 index 0000000000..eb57c10e2e --- /dev/null +++ b/server/test/mappers.ts @@ -0,0 +1,51 @@ +import { Selectable } from 'kysely'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; + +export const getForStorageTemplate = (asset: ReturnType) => { + return { + id: asset.id, + ownerId: asset.ownerId, + livePhotoVideoId: asset.livePhotoVideoId, + type: asset.type, + isExternal: asset.isExternal, + checksum: asset.checksum, + timeZone: asset.exifInfo.timeZone, + fileCreatedAt: asset.fileCreatedAt, + originalPath: asset.originalPath, + originalFileName: asset.originalFileName, + fileSizeInByte: asset.exifInfo.fileSizeInByte, + files: asset.files, + make: asset.exifInfo.make, + model: asset.exifInfo.model, + lensModel: asset.exifInfo.lensModel, + isEdited: asset.isEdited, + }; +}; + +export const getAsDetectedFace = (face: ReturnType) => ({ + faces: [ + { + boundingBox: { + x1: face.boundingBoxX1, + y1: face.boundingBoxY1, + x2: face.boundingBoxX2, + y2: face.boundingBoxY2, + }, + embedding: '[1, 2, 3, 4]', + score: 0.2, + }, + ], + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, +}); + +export const getForFacialRecognitionJob = ( + face: ReturnType, + asset: Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null, +) => ({ + ...face, + asset, + faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' }, +}); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 29e7ea7039..477414dafe 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,12 +1,15 @@ import { Kysely } from 'kysely'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetMetadataKey, AssetStatus, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { OcrRepository } from 'src/repositories/ocr.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; @@ -25,6 +28,7 @@ const setup = (db?: Kysely) => { database: db || defaultDatabase, real: [ AssetRepository, + AssetEditRepository, AssetJobRepository, AlbumRepository, AccessRepository, @@ -32,7 +36,7 @@ const setup = (db?: Kysely) => { StackRepository, UserRepository, ], - mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], + mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository, OcrRepository], }); }; @@ -398,6 +402,23 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with time zone UTC+0', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' }); + + await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); }); describe('updateAll', () => { @@ -456,7 +477,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets with timezone', async () => { + it('should relatively update assets with timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -477,7 +498,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets and set a timezone', async () => { + it('should relatively update assets and set a timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -497,6 +518,26 @@ describe(AssetService.name, () => { ); }); + it('should set asset time zones to UTC', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], timeZone: 'UTC' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-19T18:11:00+00:00', + timeZone: 'UTC', + }), + }), + ); + }); + it('should update dateTimeOriginal', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -530,6 +571,125 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with UTC time zone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); + }); + + describe('getOcr', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }), + ]); + }); + + it('should apply rotation', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + await ctx.database + .insertInto('asset_edit') + .values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 }) + .execute(); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ + x1: 0.6, + x2: 0.8, + x3: 0.8, + x4: 0.6, + y1: expect.any(Number), + y2: expect.any(Number), + y3: 0.3, + y4: 0.3, + }), + ]); + }); + }); + + describe('getOcr', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }), + ]); + }); + + it('should apply rotation', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + await ctx.database + .insertInto('asset_edit') + .values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 }) + .execute(); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ + x1: 0.6, + x2: 0.8, + x3: 0.8, + x4: 0.6, + y1: expect.any(Number), + y2: expect.any(Number), + y3: 0.3, + y4: 0.3, + }), + ]); + }); }); describe('upsertBulkMetadata', () => { @@ -704,4 +864,38 @@ describe(AssetService.name, () => { expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); }); }); + + describe('editAsset', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect( + sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }), + ).rejects.toThrow('Not found or no asset.edit.create access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + + const editAction = { action: AssetEditAction.Rotate, parameters: { angle: 90 } } as const; + await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({ + assetId: asset.id, + edits: [editAction], + }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ isEdited: true }), + ); + await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editAction]); + }); + }); }); diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts index acc51374d1..a43d0de9b9 100644 --- a/server/test/medium/specs/services/shared-link.service.spec.ts +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -90,7 +90,7 @@ describe(SharedLinkService.name, () => { assetIds: assets.map(({ asset }) => asset.id), }); - await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({ + await expect(sut.getMine({ user, sharedLink }, [])).resolves.toMatchObject({ assets: assets.map(({ asset }) => expect.objectContaining({ id: asset.id })), }); }); @@ -114,7 +114,7 @@ describe(SharedLinkService.name, () => { assetIds: [asset.id], }); - await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({ + await expect(sut.getMine({ user, sharedLink }, [])).resolves.toMatchObject({ assets: [expect.objectContaining({ id: asset.id })], }); @@ -122,6 +122,6 @@ describe(SharedLinkService.name, () => { assetIds: [asset.id], }); - await expect(sut.getMine({ user, sharedLink }, {})).resolves.toHaveProperty('assets', []); + await expect(sut.getMine({ user, sharedLink }, [])).resolves.toHaveProperty('assets', []); }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 55dcf6456f..68667fa109 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -51,7 +51,13 @@ export const newAssetRepositoryMock = (): Mocked> => { - return { - shutdown: vitest.fn(), - getExtensionVersions: vitest.fn(), - getVectorExtension: 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), - dropExtension: vitest.fn(), - updateVectorExtension: vitest.fn(), - reindexVectorsIfNeeded: vitest.fn(), - getDimensionSize: vitest.fn(), - setDimensionSize: vitest.fn(), - deleteAllSearchEmbeddings: vitest.fn(), - prewarm: vitest.fn(), - runMigrations: vitest.fn(), - revertLastMigration: vitest.fn(), - withLock: vitest.fn().mockImplementation((_, function_: () => Promise) => function_()), - tryLock: vitest.fn(), - isBusy: vitest.fn(), - wait: vitest.fn(), - migrateFilePaths: vitest.fn(), - }; -}; diff --git a/server/test/utils.ts b/server/test/utils.ts index b73ca96df3..e22e42c230 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -76,7 +76,6 @@ import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositorie import { newAssetRepositoryMock } from 'test/repositories/asset.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 { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; @@ -281,6 +280,14 @@ export const getMocks = () => { const loggerMock = { setContext: () => {} }; const configMock = { getEnv: () => ({}) }; + // eslint-disable-next-line no-sparse-arrays + const databaseMock = automock(DatabaseRepository, { args: [, loggerMock], strict: false }); + + databaseMock.withLock.mockImplementation((_type, fn) => fn()); + databaseMock.getPostgresVersion = vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'); + databaseMock.getPostgresVersionRange = vitest.fn().mockReturnValue('>=14.0.0'); + databaseMock.createExtension = vitest.fn().mockResolvedValue(void 0); + const mocks: ServiceMocks = { access: newAccessRepositoryMock(), // eslint-disable-next-line no-sparse-arrays @@ -297,7 +304,7 @@ export const getMocks = () => { assetJob: automock(AssetJobRepository), app: automock(AppRepository, { strict: false }), config: newConfigRepositoryMock(), - database: newDatabaseRepositoryMock(), + database: databaseMock, downloadRepository: automock(DownloadRepository, { strict: false }), duplicateRepository: automock(DuplicateRepository), email: automock(EmailRepository, { args: [loggerMock] }), diff --git a/web/.nvmrc b/web/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/web/mise.toml b/web/mise.toml index 5aca2d737d..00b2b30c6b 100644 --- a/web/mise.toml +++ b/web/mise.toml @@ -1,56 +1,46 @@ [tasks.install] run = "pnpm install --filter immich-web --frozen-lockfile" -[tasks."svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "svelte-kit sync" - [tasks.build] -env._.path = "./node_modules/.bin" -run = "vite build" +run = "pnpm run build" [tasks."build-stats"] -env.BUILD_STATS = "true" -env._.path = "./node_modules/.bin" -run = "vite build" +run = "pnpm run build:stats" [tasks.preview] -env._.path = "./node_modules/.bin" -run = "vite preview" +run = "pnpm run preview" [tasks.start] -env._.path = "./node_modules/.bin" -run = "vite dev --host 0.0.0.0 --port 3000" +depends = [":install", "//:sdk:install", "//:sdk:build"] +run = "pnpm run dev" + +[tasks."start-demo"] +env.IMMICH_SERVER_URL = "https://demo.immich.app" +run = { task = ":start" } [tasks.test] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "vitest" +run = "pnpm run test" [tasks.format] -env._.path = "./node_modules/.bin" -run = "prettier --check ." +run = "pnpm run format" [tasks."format-fix"] -env._.path = "./node_modules/.bin" -run = "prettier --write ." +run = "pnpm run format:fix" [tasks.lint] -env._.path = "./node_modules/.bin" -run = "eslint . --max-warnings 0 --concurrency 4" +run = "pnpm run lint" [tasks."lint-fix"] -run = { task = "lint --fix" } +run = "pnpm run lint:fix" -[tasks.check] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "tsc --noEmit" +[tasks.check-typescript] +run = "pnpm run check:typescript" [tasks."check-svelte"] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "svelte-check --no-tsconfig --fail-on-warnings" +run = "pnpm run check:svelte" + +[tasks.check] +run = { tasks = [":check-typescript", ":check-svelte"] } [tasks.checklist] run = [ diff --git a/web/package.json b/web/package.json index 96d9014bcd..b45e89fc97 100644 --- a/web/package.json +++ b/web/package.json @@ -26,9 +26,9 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", - "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.62.0", - "@mapbox/mapbox-gl-rtl-text": "0.2.3", + "@immich/sdk": "workspace:*", + "@immich/ui": "^0.64.0", + "@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0", @@ -37,10 +37,10 @@ "@photo-sphere-viewer/settings-plugin": "^5.14.0", "@photo-sphere-viewer/video-plugin": "^5.14.0", "@types/geojson": "^7946.0.16", - "@zoom-image/core": "^0.41.0", + "@zoom-image/core": "^0.42.0", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", - "fabric": "^6.5.4", + "fabric": "^7.0.0", "geo-coordinates-parser": "^1.7.4", "geojson": "^0.5.0", "handlebars": "^4.7.8", @@ -70,7 +70,7 @@ "@koddsson/eslint-plugin-tscompat": "^0.2.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/enhanced-img": "^0.9.0", + "@sveltejs/enhanced-img": "^0.10.0", "@sveltejs/kit": "^2.27.1", "@sveltejs/vite-plugin-svelte": "6.2.4", "@tailwindcss/vite": "^4.1.7", @@ -98,7 +98,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.48.0", + "svelte": "5.51.5", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", @@ -108,6 +108,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/web/src/app.css b/web/src/app.css index dc2d3bf3c3..3a4d29b466 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -148,6 +148,10 @@ color: #3a3a3a; } + body.asset-viewer-open { + background-color: black; + } + input:focus-visible { outline-offset: 0px !important; outline: none !important; diff --git a/web/src/lib/actions/context-menu-navigation.ts b/web/src/lib/actions/context-menu-navigation.ts index 89b7b76d24..9d318d35b0 100644 --- a/web/src/lib/actions/context-menu-navigation.ts +++ b/web/src/lib/actions/context-menu-navigation.ts @@ -98,7 +98,7 @@ export const contextMenuNavigation: Action = (node, option const { destroy } = shortcuts(node, [ { shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) }, { shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) }, - { shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) }, + { shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event), preventDefault: false }, { shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) }, { shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) }, ]); diff --git a/web/src/lib/actions/focus-outside.ts b/web/src/lib/actions/focus-outside.ts index c302e33d4c..829497ccdb 100644 --- a/web/src/lib/actions/focus-outside.ts +++ b/web/src/lib/actions/focus-outside.ts @@ -1,3 +1,5 @@ +import { on } from 'svelte/events'; + interface Options { onFocusOut?: (event: FocusEvent) => void; } @@ -19,11 +21,11 @@ export function focusOutside(node: HTMLElement, options: Options = {}) { } }; - node.addEventListener('focusout', handleFocusOut); + const off = on(node, 'focusout', handleFocusOut); return { destroy() { - node.removeEventListener('focusout', handleFocusOut); + off(); }, }; } diff --git a/web/src/lib/actions/shortcut.ts b/web/src/lib/actions/shortcut.ts index 8f01ce8924..b047dfc391 100644 --- a/web/src/lib/actions/shortcut.ts +++ b/web/src/lib/actions/shortcut.ts @@ -1,112 +1,9 @@ -import type { ActionReturn } from 'svelte/action'; - -export type Shortcut = { - key: string; - alt?: boolean; - ctrl?: boolean; - shift?: boolean; - meta?: boolean; -}; - -export type ShortcutOptions = { - shortcut: Shortcut; - /** If true, the event handler will not execute if the event comes from an input field */ - ignoreInputFields?: boolean; - onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown; - preventDefault?: boolean; -}; - -export const shortcutLabel = (shortcut: Shortcut) => { - let label = ''; - - if (shortcut.ctrl) { - label += 'Ctrl '; - } - if (shortcut.alt) { - label += 'Alt '; - } - if (shortcut.meta) { - label += 'Cmd '; - } - if (shortcut.shift) { - label += '⇧'; - } - label += shortcut.key.toUpperCase(); - - return label; -}; - -/** Determines whether an event should be ignored. The event will be ignored if: - * - The element dispatching the event is not the same as the element which the event listener is attached to - * - The element dispatching the event is an input field - * - The element dispatching the event is a map canvas - */ -export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => { - if (event.target === event.currentTarget) { - return false; - } - const type = (event.target as HTMLInputElement).type; - return ( - ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type) || - (event.target instanceof HTMLCanvasElement && event.target.classList.contains('maplibregl-canvas')) - ); -}; - -export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => { - return ( - shortcut.key.toLowerCase() === event.key.toLowerCase() && - Boolean(shortcut.alt) === event.altKey && - Boolean(shortcut.ctrl) === event.ctrlKey && - Boolean(shortcut.shift) === event.shiftKey && - Boolean(shortcut.meta) === event.metaKey - ); -}; - -/** Bind a single keyboard shortcut to node. */ -export const shortcut = ( - node: T, - option: ShortcutOptions, -): ActionReturn> => { - const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]); - - return { - update(newOption) { - shortcutsUpdate?.([newOption]); - }, - destroy, - }; -}; - -/** Binds multiple keyboard shortcuts to node */ -export const shortcuts = ( - node: T, - options: ShortcutOptions[], -): ActionReturn[]> => { - function onKeydown(event: KeyboardEvent) { - const ignoreShortcut = shouldIgnoreEvent(event); - for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) { - if (ignoreInputFields && ignoreShortcut) { - continue; - } - - if (matchesShortcut(event, shortcut)) { - if (preventDefault) { - event.preventDefault(); - } - onShortcut(event as KeyboardEvent & { currentTarget: T }); - return; - } - } - } - - node.addEventListener('keydown', onKeydown); - - return { - update(newOptions) { - options = newOptions; - }, - destroy() { - node.removeEventListener('keydown', onKeydown); - }, - }; -}; +export { + matchesShortcut, + shortcut, + shortcutLabel, + shortcuts, + shouldIgnoreEvent, + type Shortcut, + type ShortcutOptions, +} from '@immich/ui'; diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte deleted file mode 100644 index ae8d1199e0..0000000000 --- a/web/src/lib/components/ActionButton.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -{#if icon && isEnabled(action)} - onAction(action)} /> -{/if} diff --git a/web/src/lib/components/SharedLinkExpiration.svelte b/web/src/lib/components/SharedLinkExpiration.svelte index f8f6167084..bfe1241482 100644 --- a/web/src/lib/components/SharedLinkExpiration.svelte +++ b/web/src/lib/components/SharedLinkExpiration.svelte @@ -1,21 +1,16 @@
- + + + + +
+ {#each expiredDateOptions as option (option.value)} + + {/each} +
diff --git a/web/src/lib/components/SharedLinkFormFields.spec.ts b/web/src/lib/components/SharedLinkFormFields.spec.ts index 9c65c43833..18efa46c65 100644 --- a/web/src/lib/components/SharedLinkFormFields.spec.ts +++ b/web/src/lib/components/SharedLinkFormFields.spec.ts @@ -1,4 +1,4 @@ -import { render } from '@testing-library/svelte'; +import { renderWithTooltips } from '$tests/helpers'; import userEvent from '@testing-library/user-event'; import SharedLinkFormFields from './SharedLinkFormFields.svelte'; @@ -7,16 +7,14 @@ describe('SharedLinkFormFields component', () => { element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true'; it('turns downloads off when metadata is disabled', async () => { - const { container } = render(SharedLinkFormFields, { - props: { - slug: '', - password: '', - description: '', - allowDownload: true, - allowUpload: false, - showMetadata: true, - expiresAt: null, - }, + const { container } = renderWithTooltips(SharedLinkFormFields, { + slug: '', + password: '', + description: '', + allowDownload: true, + allowUpload: false, + showMetadata: true, + expiresAt: null, }); const user = userEvent.setup(); diff --git a/web/src/lib/components/SharedLinkFormFields.svelte b/web/src/lib/components/SharedLinkFormFields.svelte index 1e7b3b754b..42019fe54e 100644 --- a/web/src/lib/components/SharedLinkFormFields.svelte +++ b/web/src/lib/components/SharedLinkFormFields.svelte @@ -11,7 +11,6 @@ allowUpload: boolean; showMetadata: boolean; expiresAt: string | null; - createdAt?: string; }; let { @@ -22,7 +21,6 @@ allowUpload = $bindable(), showMetadata = $bindable(), expiresAt = $bindable(), - createdAt, }: Props = $props(); $effect(() => { @@ -50,7 +48,7 @@ - + diff --git a/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte index e88734c7d9..de455380a9 100644 --- a/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte +++ b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte @@ -23,7 +23,7 @@ {$t('admin.storage_template_date_time_description')} {$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })}
diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte index c99a5f6407..0969b60d29 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -1,9 +1,8 @@ - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6754ad70cf..884929845b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -1,9 +1,7 @@ - +
- - - - - - - - - - - + + + + + + + + + + + {#if isOwner} {/if} - + {#if isOwner} @@ -178,18 +133,15 @@ {/if} - - + + - {#if !isLocked} - {#if asset.isTrashed} - - {:else} - - - {/if} + {#if !isLocked && asset.isTrashed} + {/if} + + {#if isOwner} {#if stack} @@ -251,10 +203,10 @@ {/if} {#if isOwner}
- - - - + + + + {/if} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 848870b654..b09c663aaf 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,4 +1,5 @@ - + @@ -570,25 +577,22 @@
{/if} - {#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor} + {#if showDetailPanel || assetViewerManager.isShowEditor}
- -
- {/if} - - {#if assetViewerManager.isShowEditor} -
- + {#if showDetailPanel} +
+ +
+ {:else if assetViewerManager.isShowEditor} +
+ +
+ {/if}
{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts b/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts new file mode 100644 index 0000000000..3175bd8194 --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts @@ -0,0 +1,65 @@ +import { assetFactory } from '@test-data/factories/asset-factory'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import DetailPanelDescription from './detail-panel-description.svelte'; + +describe('DetailPanelDescription', () => { + it('clears unsaved draft on asset change', async () => { + const user = userEvent.setup(); + + const assetA = assetFactory.build({ + id: 'asset-a', + exifInfo: { description: '' }, + }); + const assetB = assetFactory.build({ + id: 'asset-b', + exifInfo: { description: '' }, + }); + + const { rerender } = render(DetailPanelDescription, { + props: { + asset: assetA, + isOwner: true, + }, + }); + + const textarea = screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement; + await user.type(textarea, 'unsaved draft'); + expect(textarea).toHaveValue('unsaved draft'); + + await rerender({ + asset: assetB, + isOwner: true, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue(''); + }); + + it('updates description on asset switch', async () => { + const assetA = assetFactory.build({ + id: 'asset-a', + exifInfo: { description: 'first description' }, + }); + const assetB = assetFactory.build({ + id: 'asset-b', + exifInfo: { description: 'second description' }, + }); + + const { rerender } = render(DetailPanelDescription, { + props: { + asset: assetA, + isOwner: true, + }, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue('first description'); + + await rerender({ + asset: assetB, + isOwner: true, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue('second description'); + }); +}); diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index bc3929f3dd..9aeb7855b6 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -13,10 +13,10 @@ let { asset, isOwner }: Props = $props(); - let currentDescription = $derived(asset.exifInfo?.description ?? ''); - let description = $derived(currentDescription); + let description = $derived(asset.exifInfo?.description ?? ''); const handleFocusOut = async () => { + const currentDescription = asset.exifInfo?.description ?? ''; if (description === currentDescription) { return; } diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 5b18dbb4e3..1d597062cb 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -12,6 +12,8 @@ let { asset }: Props = $props(); + const assetId = $derived(asset.id); + const loadAssetData = async (id: string) => { const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); return URL.createObjectURL(data); @@ -19,7 +21,7 @@
- {#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} + {#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])} {:then [data, { default: PhotoSphereViewer }]} diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index e64b674ac1..6f6caad0fc 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -1,6 +1,6 @@ -
- +
- - -
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index f671aa1b1c..f4ba6868e0 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -2,8 +2,10 @@ import { shortcuts } from '$lib/actions/shortcut'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; + import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils'; import { EquirectangularAdapter, Viewer, @@ -27,6 +29,17 @@ strokeLinejoin: 'round', }; + // Adapted as well as possible from classlist 'border-2 border-blue-500 bg-blue-500/10 hover:border-blue-600 hover:border-3' + const OCR_BOX_SVG_STYLE = { + fill: 'var(--color-blue-500)', + fillOpacity: '0.1', + stroke: 'var(--color-blue-500)', + strokeWidth: '2px', + }; + + const OCR_TOOLTIP_HTML_CLASS = + 'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text'; + type Props = { panorama: string | { source: string }; originalPanorama?: string | { source: string }; @@ -96,6 +109,59 @@ } }); + $effect(() => { + updateOcrBoxes(ocrManager.showOverlay, ocrManager.data); + }); + + /** Use updateOnly=true on zoom, pan, or resize. */ + const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => { + if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) { + return; + } + const markersPlugin = viewer.getPlugin(MarkersPlugin); + if (!showOverlay) { + markersPlugin.clearMarkers(); + return; + } + if (!updateOnly) { + markersPlugin.clearMarkers(); + } + + const boxes = getOcrBoundingBoxesAtSize(ocrData, { + width: viewer.state.textureData.panoData.croppedWidth, + height: viewer.state.textureData.panoData.croppedHeight, + }); + + for (const [index, box] of boxes.entries()) { + const points = box.points.map((p) => texturePointToViewerPoint(viewer, p)); + const { matrix, width, height } = calculateBoundingBoxMatrix(points); + + const fontSize = (1.4 * width) / box.text.length; // fits almost all strings within the box, depends on font family + const transform = `matrix3d(${matrix.join(',')})`; + const content = `
${box.text}
`; + + if (updateOnly) { + markersPlugin.updateMarker({ + id: `box_${index}`, + polygonPixels: box.points.map((b) => [b.x, b.y]), + tooltip: { content }, + }); + } else { + markersPlugin.addMarker({ + id: `box_${index}`, + polygonPixels: box.points.map((b) => [b.x, b.y]), + svgStyle: OCR_BOX_SVG_STYLE, + tooltip: { content, trigger: 'click' }, + }); + } + } + }; + + const texturePointToViewerPoint = (viewer: Viewer, point: Point) => { + const spherical = viewer.dataHelper.textureCoordsToSphericalCoords({ textureX: point.x, textureY: point.y }); + return viewer.dataHelper.sphericalCoordsToViewerCoords(spherical); + }; + const onZoom = () => { viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 }); }; @@ -160,7 +226,20 @@ viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true }); } - return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + const onReadyHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, false); + const updateHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, true); + viewer.addEventListener(events.ReadyEvent.type, onReadyHandler); + viewer.addEventListener(events.PositionUpdatedEvent.type, updateHandler); + viewer.addEventListener(events.SizeUpdatedEvent.type, updateHandler); + viewer.addEventListener(events.ZoomUpdatedEvent.type, updateHandler, { passive: true }); + + return () => { + viewer.removeEventListener(events.ReadyEvent.type, onReadyHandler); + viewer.removeEventListener(events.PositionUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.SizeUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.ZoomUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + }; }); onDestroy(() => { @@ -176,3 +255,25 @@
+ + diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2101107f6e..61181acbc8 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -57,7 +57,10 @@ $effect.pre(() => { void asset.id; - untrack(() => assetViewerManager.resetZoomState()); + untrack(() => { + assetViewerManager.resetZoomState(); + $boundingBoxesArray = []; + }); }); onDestroy(() => { diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 8270646470..5604e6f59d 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -223,6 +223,7 @@ bind:this={element} data-asset={asset.id} data-thumbnail-focus-container + data-selected={selected ? true : undefined} tabindex={0} role="link" > diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 304b5b278e..49fb7fa6b9 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -10,7 +10,6 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -25,6 +24,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; @@ -34,7 +34,7 @@ import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; - import { IconButton, toastManager } from '@immich/ui'; + import { ActionButton, IconButton, toastManager } from '@immich/ui'; import { mdiCardsOutline, mdiChevronDown, @@ -48,7 +48,6 @@ mdiImageSearch, mdiPause, mdiPlay, - mdiPlus, mdiSelectAll, mdiVolumeHigh, mdiVolumeOff, @@ -68,7 +67,8 @@ let currentMemoryAssetFull = $derived.by(async () => current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, ); - let currentTimelineAssets = $derived([ + let currentTimelineAssets = $derived(current?.memory.assets ?? []); + let viewerAssets = $derived([ ...(current?.previousMemory?.assets ?? []), ...(current?.memory.assets ?? []), ...(current?.nextMemory?.assets ?? []), @@ -328,6 +328,7 @@ assets={assetInteraction.selectedAssets} clearSelect={() => cancelMultiselect(assetInteraction)} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} - - - - + @@ -657,6 +655,7 @@ > { try { - sharedLink = await getMySharedLink({ password, key, slug }); + sharedLink = await sharedLinkLogin({ key, slug, sharedLinkLoginDto: { password } }); setSharedLink(sharedLink); passwordRequired = false; title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich'; diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts index a078e55762..9257c4585a 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -28,15 +28,15 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ }); describe('Album Modal', () => { - it('non-shared with no albums configured yet shows message and new', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('no albums configured yet shows message and new', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const modalRows = converter.toModalRows('', [], [], -1, []); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); }); - it('non-shared with no matching albums shows message and new', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('no matching albums shows message and new', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const modalRows = converter.toModalRows( 'matches_nothing', [], @@ -48,8 +48,8 @@ describe('Album Modal', () => { expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); }); - it('non-shared displays single albums', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('displays single albums', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []); @@ -60,8 +60,8 @@ describe('Album Modal', () => { ]); }); - it('non-shared displays multiple albums and recents', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('displays multiple albums and recents', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); @@ -87,31 +87,8 @@ describe('Album Modal', () => { ]); }); - it('shared only displays albums and no recents', () => { - const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); - const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); - const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); - const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); - const modalRows = converter.toModalRows( - '', - [holidayAlbum, constructionAlbum], - [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], - -1, - [], - ); - - expect(modalRows).toStrictEqual([ - createNewAlbumRow(false), - createAlbumRow(holidayAlbum, false), - createAlbumRow(constructionAlbum, false), - createAlbumRow(birthdayAlbum, false), - createAlbumRow(christmasAlbum, false), - ]); - }); - it('search changes messaging and removes recent and non-matching albums', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); @@ -132,7 +109,7 @@ describe('Album Modal', () => { }); it('selection can select new album row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []); @@ -148,7 +125,7 @@ describe('Album Modal', () => { }); it('selection can select recent row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []); @@ -164,7 +141,7 @@ describe('Album Modal', () => { }); it('selection can select last row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []); diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts index e65d42b183..56246ac6c4 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -27,12 +27,10 @@ export const isSelectableRowType = (type: AlbumModalRowType) => const $t = get(t); export class AlbumModalRowConverter { - private readonly shared: boolean; private readonly sortBy: string; private readonly orderBy: string; - constructor(shared: boolean, sortBy: string, orderBy: string) { - this.shared = shared; + constructor(sortBy: string, orderBy: string) { this.sortBy = sortBy; this.orderBy = orderBy; } @@ -44,8 +42,8 @@ export class AlbumModalRowConverter { selectedRowIndex: number, multiSelectedAlbumIds: string[], ): AlbumModalRow[] { - // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. - const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; + // only show recent albums if no search was entered + const recentAlbumsToShow = search.length === 0 ? recentAlbums : []; const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }]; const filteredAlbums = sortAlbums( @@ -71,12 +69,10 @@ export class AlbumModalRowConverter { } } - if (!this.shared) { - rows.push({ - type: AlbumModalRowType.SECTION, - text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), - }); - } + rows.push({ + type: AlbumModalRowType.SECTION, + text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), + }); const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length; for (const [i, album] of filteredAlbums.entries()) { diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index bdcacdf0ce..c44ded9b9c 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -34,6 +34,7 @@ type Props = { assets: AssetResponseDto[]; + viewerAssets?: AssetResponseDto[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; @@ -48,6 +49,7 @@ let { assets = $bindable(), + viewerAssets, assetInteraction, disableAssetSelect = false, showArchiveIcon = false, @@ -61,6 +63,7 @@ }: Props = $props(); let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore; + const navigationAssets = $derived(viewerAssets ?? assets); const geometry = $derived( getJustifiedLayoutFromAssets(assets, { @@ -282,12 +285,12 @@ ); const handleRandom = async (): Promise<{ id: string } | undefined> => { - if (assets.length === 0) { + if (navigationAssets.length === 0) { return; } try { - const randomIndex = Math.floor(Math.random() * assets.length); - const asset = assets[randomIndex]; + const randomIndex = Math.floor(Math.random() * navigationAssets.length); + const asset = navigationAssets[randomIndex]; await navigateToAsset(asset); return asset; @@ -344,8 +347,8 @@ const assetCursor = $derived({ current: $viewingAsset, - nextAsset: getNextAsset(assets, $viewingAsset), - previousAsset: getPreviousAsset(assets, $viewingAsset), + nextAsset: getNextAsset(navigationAssets, $viewingAsset), + previousAsset: getPreviousAsset(navigationAssets, $viewingAsset), }); diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index f008df4cb8..0b19306d6e 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -1,5 +1,5 @@
- {#if $isPurchased && $preferences.purchase.showSupportBadge} + {#if authManager.isPurchased && $preferences.purchase.showSupportBadge} - {:else if !$isPurchased && showBuyButton && getAccountAge() > 14} + {:else if !authManager.isPurchased && showBuyButton && getAccountAge() > 14}
-
+
{#each subItems as item (item)}
{ - if (!$isPurchased) { + if (!authManager.isPurchased) { return; } @@ -73,7 +72,7 @@ } await deleteIndividualProductKey(); - purchaseStore.setPurchaseStatus(false); + authManager.isPurchased = false; } catch (error) { handleError(error, $t('errors.failed_to_remove_product_key')); } @@ -92,21 +91,21 @@ } await deleteServerProductKey(); - purchaseStore.setPurchaseStatus(false); + authManager.isPurchased = false; } catch (error) { handleError(error, $t('errors.failed_to_remove_product_key')); } }; const onProductActivated = async () => { - purchaseStore.setPurchaseStatus(true); + authManager.isPurchased = true; await checkPurchaseInfo(); };
- {#if $isPurchased} + {#if authManager.isPurchased}
{ if (isAlbum) { - const albums = await modalManager.show(AlbumPickerModal, { shared: false }); + const albums = await modalManager.show(AlbumPickerModal); if (albums && albums.length > 0) { const newValue = multiple ? albums.map((album) => album.id) : albums[0].id; onchange(newValue); diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index c7181e04c6..389ebbefab 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -6,11 +6,8 @@ export enum AssetAction { TRASH = 'trash', DELETE = 'delete', RESTORE = 'restore', - ADD = 'add', - ADD_TO_ALBUM = 'add-to-album', STACK = 'stack', UNSTACK = 'unstack', - KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack', SET_VISIBILITY_LOCKED = 'set-visibility-locked', @@ -340,8 +337,8 @@ export const langs: Lang[] = [ { name: 'Chinese (Simplified)', code: 'zh-CN', - weblateCode: 'zh_SIMPLIFIED', - loader: () => import('$i18n/zh_SIMPLIFIED.json'), + weblateCode: 'zh_Hans', + loader: () => import('$i18n/zh_Hans.json'), }, { name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) }, ]; diff --git a/web/src/lib/managers/AssetCacheManager.svelte.ts b/web/src/lib/managers/AssetCacheManager.svelte.ts index f3c85acfa5..b90cf565c5 100644 --- a/web/src/lib/managers/AssetCacheManager.svelte.ts +++ b/web/src/lib/managers/AssetCacheManager.svelte.ts @@ -1,25 +1,23 @@ +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk'; +import { getAssetInfo, getAssetOcr } from '@immich/sdk'; const defaultSerializer = (params: K) => JSON.stringify(params); -class AsyncCache { +class AsyncCache { #cache = new Map(); - async getOrFetch( - params: K, - fetcher: (params: K) => Promise, - keySerializer: (params: K) => string = defaultSerializer, - updateCache: boolean, - ): Promise { - const cacheKey = keySerializer(params); + constructor(private fetcher: (params: K) => Promise) {} + + async getOrFetch(params: K, updateCache: boolean): Promise { + const cacheKey = defaultSerializer(params); const cached = this.#cache.get(cacheKey); if (cached) { return cached; } - const value = await fetcher(params); + const value = await this.fetcher(params); if (value && updateCache) { this.#cache.set(cacheKey, value); } @@ -27,30 +25,43 @@ class AsyncCache { return value; } + clearKey(params: K) { + const cacheKey = defaultSerializer(params); + this.#cache.delete(cacheKey); + } + clear() { this.#cache.clear(); } } class AssetCacheManager { - #assetCache = new AsyncCache(); - #ocrCache = new AsyncCache(); + #assetCache = new AsyncCache(getAssetInfo); + #ocrCache = new AsyncCache(getAssetOcr); constructor() { eventManager.on({ - AssetEditsApplied: () => { - this.#assetCache.clear(); - this.#ocrCache.clear(); + AssetEditsApplied: (assetId) => { + this.invalidateAsset(assetId); + }, + AssetUpdate: (asset) => { + this.invalidateAsset(asset.id); }, }); } - async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) { - return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache); + async getAsset({ id, key, slug }: { id: string; key?: string; slug?: string }, updateCache = true) { + return this.#assetCache.getOrFetch({ id, key, slug }, updateCache); } async getAssetOcr(id: string) { - return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true); + return this.#ocrCache.getOrFetch({ id }, true); + } + + invalidateAsset(id: string) { + const { key, slug } = authManager.params; + this.#assetCache.clearKey({ id, key, slug }); + this.#ocrCache.clearKey({ id }); } clearAssetCache() { diff --git a/web/src/lib/managers/auth-manager.svelte.ts b/web/src/lib/managers/auth-manager.svelte.ts index e96f3c1449..317894b0ad 100644 --- a/web/src/lib/managers/auth-manager.svelte.ts +++ b/web/src/lib/managers/auth-manager.svelte.ts @@ -3,12 +3,31 @@ import { page } from '$app/state'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { Route } from '$lib/route'; import { isSharedLinkRoute } from '$lib/utils/navigation'; -import { logout } from '@immich/sdk'; +import { getAboutInfo, logout, type UserAdminResponseDto } from '@immich/sdk'; class AuthManager { + isPurchased = $state(false); isSharedLink = $derived(isSharedLinkRoute(page.route?.id)); params = $derived(this.isSharedLink ? { key: page.params.key, slug: page.params.slug } : {}); + constructor() { + eventManager.on({ + AuthUserLoaded: (user) => this.onAuthUserLoaded(user), + }); + } + + private async onAuthUserLoaded(user: UserAdminResponseDto) { + if (user.license?.activatedAt) { + authManager.isPurchased = true; + return; + } + + const serverInfo = await getAboutInfo().catch(() => undefined); + if (serverInfo?.licensed) { + authManager.isPurchased = true; + } + } + async logout() { let redirectUri; @@ -30,6 +49,7 @@ class AuthManager { globalThis.location.href = redirectUri; } } finally { + this.isPurchased = false; eventManager.emit('AuthLogout'); } } diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index a2d5c2219d..2fe2e0dfd6 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -41,7 +41,7 @@ export type Events = { AssetEditsApplied: [string]; AssetsTag: [string[]]; - AlbumAddAssets: []; + AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }]; AlbumUpdate: [AlbumResponseDto]; AlbumDelete: [AlbumResponseDto]; AlbumShare: []; diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index e21e54a6e5..ba12f4bb6c 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -102,25 +102,21 @@ export class DayGroup { } runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { - if (ids.size === 0) { - return { - moveAssets: [] as MoveAsset[], - processedIds: new SvelteSet(), - unprocessedIds: ids, - changedGeometry: false, - }; - } const unprocessedIds = new SvelteSet(ids); const processedIds = new SvelteSet(); const moveAssets: MoveAsset[] = []; let changedGeometry = false; - for (const assetId of unprocessedIds) { - const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId); - if (index === -1) { + + if (ids.size === 0) { + return { moveAssets, processedIds, unprocessedIds, changedGeometry }; + } + + for (let index = this.viewerAssets.length - 1; index >= 0; index--) { + const { id: assetId, asset } = this.viewerAssets[index]; + if (!ids.has(assetId)) { continue; } - const asset = this.viewerAssets[index].asset!; const oldTime = { ...asset.localDateTime }; const callbackResult = callback(asset); let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false; diff --git a/web/src/lib/modals/AlbumOptionsModal.svelte b/web/src/lib/modals/AlbumOptionsModal.svelte index 392389fe92..4553f022df 100644 --- a/web/src/lib/modals/AlbumOptionsModal.svelte +++ b/web/src/lib/modals/AlbumOptionsModal.svelte @@ -3,7 +3,6 @@ import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; - import { AlbumPageViewMode } from '$lib/constants'; import { getAlbumActions, handleRemoveUserFromAlbum, @@ -56,7 +55,7 @@ sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id); }; - const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS)); + const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album)); let sharedLinks: SharedLinkResponseDto[] = $state([]); diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 72f80043f5..b2420215bc 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -21,14 +21,13 @@ let selectedRowIndex: number = $state(-1); interface Props { - shared: boolean; onClose: (albums?: AlbumResponseDto[]) => void; } - let { shared, onClose }: Props = $props(); + let { onClose }: Props = $props(); onMount(async () => { - albums = await getAllAlbums({ shared: shared || undefined }); + albums = await getAllAlbums({}); recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3); loading = false; }); @@ -36,7 +35,7 @@ const multiSelectedAlbumIds: string[] = $state([]); const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0); - const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder); + const rowConverter = new AlbumModalRowConverter($albumViewSettings.sortBy, $albumViewSettings.sortOrder); const albumModalRows = $derived( rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds), ); @@ -146,7 +145,7 @@ }; - +
{#if loading} diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte index c78c45c324..9041def7be 100644 --- a/web/src/lib/modals/AppDownloadModal.svelte +++ b/web/src/lib/modals/AppDownloadModal.svelte @@ -1,5 +1,5 @@ - - -
-
- Google Play - - Get it on Google Play - -
+ +
+ + Get it on Google Play + -
- App Store - - Download on the App Store - -
+ + Download on the App Store + -
- F-Droid - - Get it on F-Droid - -
-
- - + + Get it on F-Droid + +
+ diff --git a/web/src/lib/modals/AssetAddToAlbumModal.svelte b/web/src/lib/modals/AssetAddToAlbumModal.svelte new file mode 100644 index 0000000000..b35c125d08 --- /dev/null +++ b/web/src/lib/modals/AssetAddToAlbumModal.svelte @@ -0,0 +1,27 @@ + + + diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index c0c7f8b10a..dbd5bdb118 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -10,7 +10,7 @@ import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte'; interface Props { - onClose: () => void; + onClose: (updated?: boolean) => void; assetIds: string[]; } @@ -33,7 +33,7 @@ const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false }); eventManager.emit('AssetsTag', updatedIds); - onClose(); + onClose(true); }; const handleSelect = async (option?: ComboBoxOption) => { @@ -62,6 +62,7 @@ {onClose} {onSubmit} submitText={$t('tag_assets')} + onOpenAutoFocus={(event) => event.preventDefault()} {disabled} >
diff --git a/web/src/lib/modals/PinCodeResetModal.svelte b/web/src/lib/modals/PinCodeResetModal.svelte index 024f0c8528..a51e0c3583 100644 --- a/web/src/lib/modals/PinCodeResetModal.svelte +++ b/web/src/lib/modals/PinCodeResetModal.svelte @@ -1,7 +1,7 @@ -{#if featureFlagsManager.value.passwordLogin === false} +{#if featureFlagsManager.value.passwordLogin}
{$t('reset_pin_code_description')}
@@ -37,9 +37,7 @@
{:else} - - -
{$t('reset_pin_code_description')}
-
-
+ +
{$t('reset_pin_code_description')}
+
{/if} diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index c5b09ffa1a..c233548878 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -40,7 +40,6 @@ { key: ['s'], action: $t('stack_selected_photos') }, { key: ['l'], action: $t('add_to_album') }, { key: ['t'], action: $t('tag_assets') }, - { key: ['⇧', 'l'], action: $t('add_to_shared_album') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') }, diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index ac0a1045b3..0f155df0e9 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -1,6 +1,6 @@ import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; -import { AlbumPageViewMode } from '$lib/constants'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte'; @@ -11,22 +11,27 @@ import { user } from '$lib/stores/user.store'; import { createAlbumAndRedirect } from '$lib/utils/album-utils'; import { downloadArchive } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { - addAssetsToAlbum, + addAssetsToAlbum as addToAlbum, + addAssetsToAlbums as addToAlbums, addUsersToAlbum, AlbumUserRole, + BulkIdErrorReason, deleteAlbum, removeUserFromAlbum, updateAlbumInfo, updateAlbumUser, type AlbumResponseDto, + type AlbumsAddAssetsResponseDto, + type BulkIdResponseDto, type UpdateAlbumDto, type UserResponseDto, } from '@immich/sdk'; import { modalManager, toastManager, type ActionItem } from '@immich/ui'; -import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js'; +import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js'; import { type MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; @@ -40,7 +45,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => { return { Create }; }; -export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => { +export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => { const isOwned = get(user).id === album.ownerId; const Share: ActionItem = { @@ -67,16 +72,7 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, v onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }), }; - const Close: ActionItem = { - title: $t('go_back'), - type: $t('command'), - icon: mdiArrowLeft, - onAction: () => goto(Route.albums()), - $if: () => viewMode === AlbumPageViewMode.VIEW, - shortcuts: { key: 'Escape' }, - }; - - return { Share, AddUsers, CreateSharedLink, Close }; + return { Share, AddUsers, CreateSharedLink }; }; export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => { @@ -86,7 +82,12 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse color: 'primary', icon: mdiPlusBoxOutline, $if: () => assets.length > 0, - onAction: () => addAssets(album, assets), + onAction: () => + addAssetsToAlbums( + [album.id], + assets.map(({ id }) => id), + { notify: true }, + ).then(() => undefined), }; const Upload: ActionItem = { @@ -100,18 +101,73 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse return { AddAssets, Upload }; }; -const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => { +export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], { notify }: { notify: boolean }) => { const $t = await getFormatter(); - const assetIds = assets.map(({ id }) => id); try { - const results = await addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: assetIds } }); + if (albumIds.length === 1) { + const albumId = albumIds[0]; + const results = await addToAlbum({ ...authManager.params, id: albumId, bulkIdsDto: { ids: assetIds } }); + if (notify) { + notifyAddToAlbum($t, albumId, assetIds, results); + } + } - const count = results.filter(({ success }) => success).length; - toastManager.success($t('assets_added_count', { values: { count } })); - eventManager.emit('AlbumAddAssets'); + if (albumIds.length > 1) { + const results = await addToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } }); + if (notify) { + notifyAddToAlbums($t, albumIds, assetIds, results); + } + } + + eventManager.emit('AlbumAddAssets', { assetIds, albumIds }); + return true; } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); + return false; + } +}; + +const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => { + const successCount = results.filter(({ success }) => success).length; + const duplicateCount = results.filter(({ error }) => error === 'duplicate').length; + let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); + if (successCount > 0) { + description = $t('assets_added_to_album_count', { values: { count: successCount } }); + } else if (duplicateCount > 0) { + description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } }); + } + + toastManager.custom( + { + component: ToastAction, + props: { + title: $t('info'), + color: 'primary', + description, + button: { text: $t('view_album'), color: 'primary', onClick: () => goto(Route.viewAlbum({ id: albumId })) }, + }, + }, + { timeout: 5000 }, + ); +}; + +const notifyAddToAlbums = ( + $t: MessageFormatter, + albumIds: string[], + assetIds: string[], + results: AlbumsAddAssetsResponseDto, +) => { + if (results.error === BulkIdErrorReason.Duplicate) { + toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } })); + } else if (results.error) { + toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } })); + } else { + toastManager.success( + $t('assets_added_to_albums_count', { + values: { albumTotal: albumIds.length, assetTotal: assetIds.length }, + }), + ); } }; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index f9b33d5687..bbe4d9301b 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -2,6 +2,7 @@ import { ProjectionType } from '$lib/constants'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; +import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { user as authUser, preferences } from '$lib/stores/user.store'; @@ -42,6 +43,7 @@ import { mdiMagnifyPlusOutline, mdiMotionPauseOutline, mdiMotionPlayOutline, + mdiPlus, mdiShareVariantOutline, mdiTagPlusOutline, mdiTune, @@ -59,6 +61,13 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte ctx.clearSelect(); }; + const AddToAlbum: ActionItem = { + title: $t('add_to_album'), + icon: mdiPlus, + shortcuts: [{ key: 'l' }], + onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }), + }; + const RefreshFacesJob: ActionItem = { title: $t('refresh_faces'), icon: mdiHeadSyncOutline, @@ -84,7 +93,7 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte $if: () => isAllVideos, }; - return { RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob }; + return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob }; }; export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { @@ -161,6 +170,14 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: [{ key: 'f' }], }; + const AddToAlbum: ActionItem = { + title: $t('add_to_album'), + icon: mdiPlus, + shortcuts: [{ key: 'l' }], + $if: () => asset.visibility !== AssetVisibility.Locked && !asset.isTrashed, + onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds: [asset.id] }), + }; + const Offline: ActionItem = { title: $t('asset_offline'), icon: mdiAlertOutline, @@ -260,6 +277,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = Unfavorite, PlayMotionPhoto, StopMotionPhoto, + AddToAlbum, ZoomIn, ZoomOut, Copy, diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts index 9cfc1b2c8e..48c8080269 100644 --- a/web/src/lib/stores/asset-interaction.svelte.ts +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -1,14 +1,16 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { user } from '$lib/stores/user.store'; +import type { AssetControlContext } from '$lib/types'; import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk'; -import { SvelteSet } from 'svelte/reactivity'; +import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { fromStore } from 'svelte/store'; export class AssetInteraction { - selectedAssets = $state([]); + private selectedAssetsMap = new SvelteMap(); + selectedAssets = $derived(Array.from(this.selectedAssetsMap.values())); selectAll = $state(false); hasSelectedAsset(assetId: string) { - return this.selectedAssets.some((asset) => asset.id === assetId); + return this.selectedAssetsMap.has(assetId); } selectedGroup = new SvelteSet(); assetSelectionCandidates = $state([]); @@ -16,20 +18,26 @@ export class AssetInteraction { return this.assetSelectionCandidates.some((asset) => asset.id === assetId); } assetSelectionStart = $state(null); - selectionActive = $derived(this.selectedAssets.length > 0); + selectionActive = $derived(this.selectedAssetsMap.size > 0); private user = fromStore(user); private userId = $derived(this.user.current?.id); + asControlContext(): AssetControlContext { + return { + getOwnedAssets: () => this.selectedAssets.filter((asset) => asset.ownerId === this.userId), + getAssets: () => this.selectedAssets, + clearSelect: () => this.clearMultiselect(), + }; + } + isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed)); isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive)); isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId)); selectAsset(asset: TimelineAsset) { - if (!this.hasSelectedAsset(asset.id)) { - this.selectedAssets.push(asset); - } + this.selectedAssetsMap.set(asset.id, asset); } selectAssets(assets: TimelineAsset[]) { @@ -39,10 +47,7 @@ export class AssetInteraction { } removeAssetFromMultiselectGroup(assetId: string) { - const index = this.selectedAssets.findIndex((a) => a.id == assetId); - if (index !== -1) { - this.selectedAssets.splice(index, 1); - } + this.selectedAssetsMap.delete(assetId); } addGroupToMultiselectGroup(group: string) { @@ -69,7 +74,7 @@ export class AssetInteraction { this.selectAll = false; // Multi-selection - this.selectedAssets = []; + this.selectedAssetsMap.clear(); this.selectedGroup.clear(); // Range selection diff --git a/web/src/lib/stores/ocr.svelte.spec.ts b/web/src/lib/stores/ocr.svelte.spec.ts index 5220cbb77d..1e2aeecb73 100644 --- a/web/src/lib/stores/ocr.svelte.spec.ts +++ b/web/src/lib/stores/ocr.svelte.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock the SDK vi.mock('@immich/sdk', () => ({ + getAssetInfo: vi.fn(), getAssetOcr: vi.fn(), })); diff --git a/web/src/lib/stores/purchase.store.ts b/web/src/lib/stores/purchase.store.ts deleted file mode 100644 index 4b9c61eed7..0000000000 --- a/web/src/lib/stores/purchase.store.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readonly, writable } from 'svelte/store'; - -function createPurchaseStore() { - const isPurcharsed = writable(false); - - function setPurchaseStatus(status: boolean) { - isPurcharsed.set(status); - } - - return { - isPurchased: readonly(isPurcharsed), - setPurchaseStatus, - }; -} - -export const purchaseStore = createPurchaseStore(); diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 331df7ad5f..3c1549c2cd 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -1,5 +1,4 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; -import { purchaseStore } from '$lib/stores/purchase.store'; import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; @@ -13,7 +12,6 @@ export const preferences = writable(); export const resetSavedUser = () => { user.set(undefined as unknown as UserAdminResponseDto); preferences.set(undefined as unknown as UserPreferencesResponseDto); - purchaseStore.setPurchaseStatus(false); }; eventManager.on({ diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 335ec188ea..32aa52fccb 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -77,6 +77,7 @@ websocket .on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event)) .on('on_session_delete', () => authManager.logout()) .on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id })) + .on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset)) .on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id })) .on('on_notification', () => notificationManager.refresh()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 84e386d620..73a6965dd9 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,11 +1,8 @@ -import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; -import { Route } from '$lib/route'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, withError } from '$lib/utils'; @@ -14,10 +11,7 @@ import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; import { asQueryString } from '$lib/utils/shared-links'; import { - addAssetsToAlbum as addAssets, - addAssetsToAlbums as addToAlbums, AssetVisibility, - BulkIdErrorReason, bulkTagAssets, createStack, deleteAssets, @@ -42,77 +36,6 @@ import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; import { handleError } from './handle-error'; -export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => { - const result = await addAssets({ - ...authManager.params, - id: albumId, - bulkIdsDto: { - ids: assetIds, - }, - }); - const count = result.filter(({ success }) => success).length; - const duplicateErrorCount = result.filter(({ error }) => error === 'duplicate').length; - const $t = get(t); - - if (showNotification) { - let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); - if (count > 0) { - description = $t('assets_added_to_album_count', { values: { count } }); - } else if (duplicateErrorCount > 0) { - description = $t('assets_were_part_of_album_count', { values: { count: duplicateErrorCount } }); - } - toastManager.custom( - { - component: ToastAction, - props: { - title: $t('info'), - color: 'primary', - description, - button: { - text: $t('view_album'), - color: 'primary', - onClick() { - return goto(Route.viewAlbum({ id: albumId })); - }, - }, - }, - }, - { timeout: 5000 }, - ); - } -}; - -export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => { - const result = await addToAlbums({ - ...authManager.params, - albumsAddAssetsDto: { - albumIds, - assetIds, - }, - }); - - if (!showNotification) { - return result; - } - - if (showNotification) { - const $t = get(t); - - if (result.error === BulkIdErrorReason.Duplicate) { - toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } })); - return result; - } - if (result.error) { - toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } })); - return result; - } - toastManager.success( - $t('assets_added_to_albums_count', { values: { albumTotal: albumIds.length, assetTotal: assetIds.length } }), - ); - return result; - } -}; - export const tagAssets = async ({ assetIds, tagIds, @@ -213,7 +136,7 @@ export const downloadArchive = async (fileName: string, options: Omit downloadManager.update(downloadKey, event.loaded), }); @@ -443,13 +366,15 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt try { for (const monthGroup of timelineManager.months) { - await timelineManager.loadMonthGroup(monthGroup.yearMonth); + if (!monthGroup.isLoaded) { + await timelineManager.loadMonthGroup(monthGroup.yearMonth); + } if (!assetInteraction.selectAll) { assetInteraction.clearMultiselect(); break; // Cancelled } - assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()])); + assetInteraction.selectAssets([...monthGroup.assetsIterator()]); for (const dateGroup of monthGroup.dayGroups) { assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle); diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 1be65638e6..63c7d4e5c8 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,10 +1,9 @@ import { browser } from '$app/environment'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { Route } from '$lib/route'; -import { purchaseStore } from '$lib/stores/purchase.store'; import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; import { userInteraction } from '$lib/stores/user.svelte'; -import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; +import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { DateTime } from 'luxon'; import { get } from 'svelte/store'; @@ -18,19 +17,12 @@ export const loadUser = async () => { try { let user = get(user$); let preferences = get(preferences$); - let serverInfo; if ((!user || !preferences) && hasAuthCookie()) { - [user, preferences, serverInfo] = await Promise.all([getMyUser(), getMyPreferences(), getAboutInfo()]); + [user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]); user$.set(user); preferences$.set(preferences); - eventManager.emit('AuthUserLoaded', user); - - // Check for license status - if (serverInfo.licensed || user.license?.activatedAt) { - purchaseStore.setPurchaseStatus(true); - } } return user; } catch { diff --git a/web/src/lib/utils/base-event-manager.svelte.ts b/web/src/lib/utils/base-event-manager.svelte.ts index 1b5135dfd9..5112076988 100644 --- a/web/src/lib/utils/base-event-manager.svelte.ts +++ b/web/src/lib/utils/base-event-manager.svelte.ts @@ -15,7 +15,7 @@ const nextId = () => count++; const noop = () => {}; export class BaseEventManager { - #callbacks: EventItem[] = $state([]); + #callbacks: EventItem[] = $state.raw([]); on(subscriptions: EventMap): () => void { const cleanups = Object.entries(subscriptions).map(([event, callback]) => @@ -36,7 +36,7 @@ export class BaseEventManager { // eslint-disable-next-line @typescript-eslint/no-explicit-any const item = { id: nextId(), event, callback } as EventItem; - this.#callbacks.push(item); + this.#callbacks = [...this.#callbacks, item]; return () => { this.#callbacks = this.#callbacks.filter((current) => current.id !== item.id); diff --git a/web/src/lib/utils/cast/gcast-destination.svelte.ts b/web/src/lib/utils/cast/gcast-destination.svelte.ts index 8e72c71e0b..d85d1f513b 100644 --- a/web/src/lib/utils/cast/gcast-destination.svelte.ts +++ b/web/src/lib/utils/cast/gcast-destination.svelte.ts @@ -115,12 +115,17 @@ export class GCastDestination implements ICastDestination { // build the authenticated media request and send it to the cast device const authenticatedUrl = `${mediaUrl}&sessionKey=${sessionKey}`; const mediaInfo = new chrome.cast.media.MediaInfo(authenticatedUrl, contentType); - const request = new chrome.cast.media.LoadRequest(mediaInfo); + + // Create a queue with a single item and set it to repeat + const queueItem = new chrome.cast.media.QueueItem(mediaInfo); + const queueLoadRequest = new chrome.cast.media.QueueLoadRequest([queueItem]); + queueLoadRequest.repeatMode = chrome.cast.media.RepeatMode.SINGLE; + const successCallback = this.onMediaDiscovered.bind(this, SESSION_DISCOVERY_CAUSE.LOAD_MEDIA); this.currentUrl = mediaUrl; - return this.session.loadMedia(request, successCallback, this.onError.bind(this)); + return this.session.queueLoad(queueLoadRequest, successCallback, this.onError.bind(this)); } /// diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 8558244cfb..e33022eb37 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -1,10 +1,10 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { uploadManager } from '$lib/managers/upload-manager.svelte'; +import { addAssetsToAlbums } from '$lib/services/album.service'; import { uploadAssetsStore } from '$lib/stores/upload'; import { user } from '$lib/stores/user.store'; import { UploadState } from '$lib/types'; import { uploadRequest } from '$lib/utils'; -import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; import { asQueryString } from '$lib/utils/shared-links'; import { @@ -213,7 +213,7 @@ async function fileUploader({ if (albumId) { uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') }); - await addAssetsToAlbum(albumId, [responseData.id], false); + await addAssetsToAlbums([albumId], [responseData.id], { notify: false }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') }); } diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 97364d06f5..01f118a4e5 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -12,70 +12,58 @@ const getContainedSize = (img: HTMLImageElement): { width: number; height: numbe return { width, height }; }; +export type Point = { + x: number; + y: number; +}; + export interface OcrBox { id: string; - points: { x: number; y: number }[]; + points: Point[]; text: string; confidence: number; } -export interface BoundingBoxDimensions { - minX: number; - maxX: number; - minY: number; - maxY: number; - width: number; - height: number; - centerX: number; - centerY: number; - rotation: number; - skewX: number; - skewY: number; -} - /** - * Calculate bounding box dimensions and properties from OCR points + * Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d. * @param points - Array of 4 corner points of the bounding box - * @returns Dimensions, rotation, and skew values for the bounding box + * @returns 4x4 matrix to transform the div with text onto the polygon defined by the corner points, and size to set on the source div. */ -export const calculateBoundingBoxDimensions = (points: { x: number; y: number }[]): BoundingBoxDimensions => { +export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => { const [topLeft, topRight, bottomRight, bottomLeft] = points; - const minX = Math.min(...points.map(({ x }) => x)); - const maxX = Math.max(...points.map(({ x }) => x)); - const minY = Math.min(...points.map(({ y }) => y)); - const maxY = Math.max(...points.map(({ y }) => y)); - const width = maxX - minX; - const height = maxY - minY; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - // Calculate rotation angle from the bottom edge (bottomLeft to bottomRight) - const rotation = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x) * (180 / Math.PI); + // Approximate width and height to prevent text distortion as much as possible + const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight)); + const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight)); - // Calculate skew angles to handle perspective distortion - // SkewX: compare left and right edges - const leftEdgeAngle = Math.atan2(bottomLeft.y - topLeft.y, bottomLeft.x - topLeft.x); - const rightEdgeAngle = Math.atan2(bottomRight.y - topRight.y, bottomRight.x - topRight.x); - const skewX = (rightEdgeAngle - leftEdgeAngle) * (180 / Math.PI); + const dx1 = topRight.x - bottomRight.x; + const dx2 = bottomLeft.x - bottomRight.x; + const dx3 = topLeft.x - topRight.x + bottomRight.x - bottomLeft.x; - // SkewY: compare top and bottom edges - const topEdgeAngle = Math.atan2(topRight.y - topLeft.y, topRight.x - topLeft.x); - const bottomEdgeAngle = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x); - const skewY = (bottomEdgeAngle - topEdgeAngle) * (180 / Math.PI); + const dy1 = topRight.y - bottomRight.y; + const dy2 = bottomLeft.y - bottomRight.y; + const dy3 = topLeft.y - topRight.y + bottomRight.y - bottomLeft.y; - return { - minX, - maxX, - minY, - maxY, - width, - height, - centerX, - centerY, - rotation, - skewX, - skewY, - }; + const det = dx1 * dy2 - dx2 * dy1; + const a13 = (dx3 * dy2 - dx2 * dy3) / det; + const a23 = (dx1 * dy3 - dx3 * dy1) / det; + + const a11 = (1 + a13) * topRight.x - topLeft.x; + const a21 = (1 + a23) * bottomLeft.x - topLeft.x; + + const a12 = (1 + a13) * topRight.y - topLeft.y; + const a22 = (1 + a23) * bottomLeft.y - topLeft.y; + + // prettier-ignore + const matrix = [ + a11 / width, a12 / width, 0, a13 / width, + a21 / height, a22 / height, 0, a23 / height, + 0, 0, 1, 0, + topLeft.x, topLeft.y, 0, 1, + ]; + + return { matrix, width, height }; }; /** @@ -87,18 +75,32 @@ export const getOcrBoundingBoxes = ( zoom: ZoomImageWheelState, photoViewer: HTMLImageElement | null, ): OcrBox[] => { - const boxes: OcrBox[] = []; - if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) { - return boxes; + return []; } const clientHeight = photoViewer.clientHeight; const clientWidth = photoViewer.clientWidth; const { width, height } = getContainedSize(photoViewer); - const imageWidth = photoViewer.naturalWidth; - const imageHeight = photoViewer.naturalHeight; + const offset = { + x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX, + y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY, + }; + + return getOcrBoundingBoxesAtSize( + ocrData, + { width: width * zoom.currentZoom, height: height * zoom.currentZoom }, + offset, + ); +}; + +export const getOcrBoundingBoxesAtSize = ( + ocrData: OcrBoundingBox[], + targetSize: { width: number; height: number }, + offset?: Point, +) => { + const boxes: OcrBox[] = []; for (const ocr of ocrData) { // Convert normalized coordinates (0-1) to actual pixel positions @@ -109,14 +111,8 @@ export const getOcrBoundingBoxes = ( { x: ocr.x3, y: ocr.y3 }, { x: ocr.x4, y: ocr.y4 }, ].map((point) => ({ - x: - (width / imageWidth) * zoom.currentZoom * point.x * imageWidth + - ((clientWidth - width) / 2) * zoom.currentZoom + - zoom.currentPositionX, - y: - (height / imageHeight) * zoom.currentZoom * point.y * imageHeight + - ((clientHeight - height) / 2) * zoom.currentZoom + - zoom.currentPositionY, + x: targetSize.width * point.x + (offset?.x ?? 0), + y: targetSize.height * point.y + (offset?.y ?? 0), })); boxes.push({ diff --git a/web/src/lib/utils/shared-links.ts b/web/src/lib/utils/shared-links.ts index e1bad6bf3a..423eda310c 100644 --- a/web/src/lib/utils/shared-links.ts +++ b/web/src/lib/utils/shared-links.ts @@ -49,7 +49,7 @@ export const loadSharedLink = async ({ }, }; } catch (error) { - if (isHttpError(error) && error.data.message === 'Invalid password') { + if (isHttpError(error) && error.data.message === 'Password required') { return { ...common, passwordRequired: true, diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 61cd1b8df0..3c32bf7de1 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,14 +1,12 @@ -const broadcast = new BroadcastChannel('immich'); +import { ServiceWorkerMessenger } from './sw-messenger'; + +const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator; +// eslint-disable-next-line compat/compat +const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined; export function cancelImageUrl(url: string | undefined | null) { - if (!url) { + if (!url || !messenger) { return; } - broadcast.postMessage({ type: 'cancel', url }); -} -export function preloadImageUrl(url: string | undefined | null) { - if (!url) { - return; - } - broadcast.postMessage({ type: 'preload', url }); + messenger.send('cancel', { url }); } diff --git a/web/src/lib/utils/sw-messenger.ts b/web/src/lib/utils/sw-messenger.ts new file mode 100644 index 0000000000..b656f3fc2c --- /dev/null +++ b/web/src/lib/utils/sw-messenger.ts @@ -0,0 +1,17 @@ +export class ServiceWorkerMessenger { + readonly #serviceWorker: ServiceWorkerContainer; + + constructor(serviceWorker: ServiceWorkerContainer) { + this.#serviceWorker = serviceWorker; + } + + /** + * Send a one-way message to the service worker. + */ + send(type: string, data: Record) { + this.#serviceWorker.controller?.postMessage({ + type, + ...data, + }); + } +} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 88baa416b8..44a0c5e678 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,7 +1,6 @@ (album.albumName = albumName)} + onUpdate={(albumName) => (album = { ...album, albumName })} /> {#if album.assetCount > 0} @@ -395,8 +407,11 @@
{/if} - - + album.description, (description) => (album = { ...album, description })} + />
{/if} @@ -438,12 +453,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - - - - + {#if assetInteraction.isAllUserOwned} assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/buy/+page.svelte b/web/src/routes/(user)/buy/+page.svelte index 305b994730..111548ba3b 100644 --- a/web/src/routes/(user)/buy/+page.svelte +++ b/web/src/routes/(user)/buy/+page.svelte @@ -4,8 +4,8 @@ import LicenseActivationSuccess from '$lib/components/shared-components/purchasing/purchase-activation-success.svelte'; import LicenseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte'; import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { Route } from '$lib/route'; - import { purchaseStore } from '$lib/stores/purchase.store'; import { Alert, Container, Stack } from '@immich/ui'; import { mdiAlertCircleOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -17,17 +17,16 @@ let { data }: Props = $props(); let showLicenseActivated = $state(false); - const { isPurchased } = purchaseStore; - + {#if data.isActivated === false} {/if} - {#if $isPurchased} + {#if authManager.isPurchased} {/if} diff --git a/web/src/routes/(user)/buy/+page.ts b/web/src/routes/(user)/buy/+page.ts index d0180b39ff..5f937c396e 100644 --- a/web/src/routes/(user)/buy/+page.ts +++ b/web/src/routes/(user)/buy/+page.ts @@ -1,4 +1,4 @@ -import { purchaseStore } from '$lib/stores/purchase.store'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { activateProduct, getActivationKey } from '$lib/utils/license-utils'; @@ -21,7 +21,7 @@ export const load = (async ({ url }) => { const response = await activateProduct(licenseKey, activationKey); if (response.activatedAt !== '') { isActivated = true; - purchaseStore.setPurchaseStatus(true); + authManager.isPurchased = true; } } } catch (error) { 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 d33c5e7474..b13146aab6 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 @@ -2,7 +2,6 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.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 AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -17,9 +16,11 @@ import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui'; + import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -68,13 +69,12 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + timelineManager.removeAssets(assetIds)} /> - - - - + 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 index 9bca4a9094..3cafdcbc5b 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -8,7 +8,6 @@ import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import Sidebar from '$lib/components/sidebar/sidebar.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -27,11 +26,10 @@ import { foldersStore } from '$lib/stores/folders.svelte'; import { preferences } from '$lib/stores/user.store'; import { cancelMultiselect } from '$lib/utils/asset-utils'; - import { getAssetControlContext } from '$lib/utils/context'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { joinPaths } from '$lib/utils/tree-utils'; - import { IconButton, Text } from '@immich/ui'; - import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; + import { ActionButton, CommandPaletteDefaultProvider, IconButton, Text } from '@immich/ui'; + import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -119,8 +117,8 @@ assets={assetInteraction.selectedAssets} clearSelect={() => cancelMultiselect(assetInteraction)} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} - + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - - cancelMultiselect(assetInteraction)} /> - cancelMultiselect(assetInteraction)} shared /> - + import { goto } from '$app/navigation'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte'; import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetVisibility } from '@immich/sdk'; - import { mdiArrowLeft, mdiPlus } from '@mdi/js'; + import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui'; + import { mdiArrowLeft } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -45,18 +45,17 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - - - - + {:else} goto(Route.sharing())}> {#snippet leading()}

- {data.partner.name}'s photos + {$t('partner_list_user_photos', { values: { user: data.partner.name } })}

{/snippet}
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 57c5730b45..d28847068b 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 @@ -12,7 +12,6 @@ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -31,6 +30,7 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { getPersonActions } from '$lib/services/person.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { locale } from '$lib/stores/preferences.store'; @@ -40,14 +40,16 @@ import { handleError } from '$lib/utils/handle-error'; import { isExternalUrl } from '$lib/utils/navigation'; import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; - import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui'; import { - mdiAccountBoxOutline, - mdiAccountMultipleCheckOutline, - mdiArrowLeft, - mdiDotsVertical, - mdiPlus, - } from '@mdi/js'; + ActionButton, + CommandPaletteDefaultProvider, + ContextMenuButton, + LoadingSpinner, + modalManager, + toastManager, + type ActionItem, + } from '@immich/ui'; + import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -461,12 +463,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index bea77bb443..dd2080a831 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -4,7 +4,6 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.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 AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -36,13 +35,12 @@ type OnLink, type OnUnlink, } from '$lib/utils/actions'; - import { getAssetControlContext } from '$lib/utils/context'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; - import { ImageCarousel } from '@immich/ui'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; + import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; let { isViewing: showAssetViewer } = assetViewingStore; @@ -130,14 +128,12 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - - - - + {#if isAllUserOwned} { + const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => { cancelMultiselect(assetInteraction); if (terms.isNotInAlbum.toString() == 'true') { @@ -248,6 +247,8 @@ + + {#if terms}
cancelMultiselect(assetInteraction)} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - - - - + {#if isAllUserOwned} + diff --git a/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.svelte b/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.svelte index 67d90bdbb8..0d91a50d1d 100644 --- a/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.svelte +++ b/web/src/routes/(user)/shared-links/(list)/[id]/edit/+page.svelte @@ -69,6 +69,5 @@ bind:allowUpload bind:showMetadata bind:expiresAt - createdAt={sharedLink.createdAt} /> diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2b498e56ea..fefd8dd032 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,7 +9,6 @@ import Sidebar from '$lib/components/sidebar/sidebar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -25,13 +24,14 @@ import SkipLink from '$lib/elements/SkipLink.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { getTagActions } from '$lib/services/tag.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences, user } from '$lib/stores/user.store'; import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; - import { Text } from '@immich/ui'; - import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js'; + import { ActionButton, CommandPaletteDefaultProvider, Text } from '@immich/ui'; + import { mdiDotsVertical, mdiTag, mdiTagMultiple } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -120,12 +120,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index f79a202cf5..d1ed7d9832 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -40,17 +40,32 @@ $effect(() => { setTranslations({ + cancel: $t('cancel'), close: $t('close'), + confirm: $t('confirm'), + expand: $t('expand'), + collapse: $t('collapse'), + search_placeholder: $t('search'), + search_no_results: $t('no_results'), + prompt_default: $t('are_you_sure_to_do_this'), show_password: $t('show_password'), hide_password: $t('hide_password'), - confirm: $t('confirm'), - cancel: $t('cancel'), + dark_theme: $t('dark_theme'), + open_menu: $t('open'), + command_palette_prompt_default: $t('command_palette_prompt'), + command_palette_to_select: $t('command_palette_to_select'), + command_palette_to_navigate: $t('command_palette_to_navigate'), + command_palette_to_close: $t('command_palette_to_close'), + command_palette_to_show_all: $t('command_palette_to_show_all'), + navigate_next: $t('next'), + navigate_previous: $t('previous'), + open_calendar: $t('open_calendar'), toast_success_title: $t('success'), toast_info_title: $t('info'), toast_warning_title: $t('warning'), toast_danger_title: $t('error'), - navigate_next: $t('next'), - navigate_previous: $t('previous'), + save: $t('save'), + supporter: $t('supporter'), }); }); diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts deleted file mode 100644 index ae6f1e1be6..0000000000 --- a/web/src/service-worker/broadcast-channel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { handleCancel, handlePreload } from './request'; - -export const installBroadcastChannelListener = () => { - const broadcast = new BroadcastChannel('immich'); - // eslint-disable-next-line unicorn/prefer-add-event-listener - broadcast.onmessage = (event) => { - if (!event.data) { - return; - } - - const url = new URL(event.data.url, event.origin); - - switch (event.data.type) { - case 'preload': { - handlePreload(url); - break; - } - - case 'cancel': { - handleCancel(url); - break; - } - } - }; -}; diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts deleted file mode 100644 index f91d8366ea..0000000000 --- a/web/src/service-worker/cache.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { version } from '$service-worker'; - -const CACHE = `cache-${version}`; - -let _cache: Cache | undefined; -const getCache = async () => { - if (_cache) { - return _cache; - } - _cache = await caches.open(CACHE); - return _cache; -}; - -export const get = async (key: string) => { - const cache = await getCache(); - if (!cache) { - return; - } - - return cache.match(key); -}; - -export const put = async (key: string, response: Response) => { - if (response.status !== 200) { - return; - } - - const cache = await getCache(); - if (!cache) { - return; - } - - cache.put(key, response.clone()); -}; - -export const prune = async () => { - for (const key of await caches.keys()) { - if (key !== CACHE) { - await caches.delete(key); - } - } -}; diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index 28336aca6a..377195b0c8 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -2,9 +2,9 @@ /// /// /// -import { installBroadcastChannelListener } from './broadcast-channel'; -import { prune } from './cache'; -import { handleRequest } from './request'; + +import { installMessageListener } from './messaging'; +import { handleFetch as handleAssetFetch } from './request'; const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/; @@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope; const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); - event.waitUntil(prune()); }; const handleInstall = (event: ExtendableEvent) => { event.waitUntil(sw.skipWaiting()); - // do not preload app resources }; const handleFetch = (event: FetchEvent): void => { @@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => { // Cache requests for thumbnails const url = new URL(event.request.url); if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) { - event.respondWith(handleRequest(event.request)); + event.respondWith(handleAssetFetch(event.request)); return; } }; @@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => { sw.addEventListener('install', handleInstall, { passive: true }); sw.addEventListener('activate', handleActivate, { passive: true }); sw.addEventListener('fetch', handleFetch, { passive: true }); -installBroadcastChannelListener(); +installMessageListener(); diff --git a/web/src/service-worker/messaging.ts b/web/src/service-worker/messaging.ts new file mode 100644 index 0000000000..b60ff055a2 --- /dev/null +++ b/web/src/service-worker/messaging.ts @@ -0,0 +1,33 @@ +/// +/// +/// +/// + +import { handleCancel } from './request'; + +const sw = globalThis as unknown as ServiceWorkerGlobalScope; + +export const installMessageListener = () => { + sw.addEventListener('message', (event) => { + if (!event.data?.type) { + return; + } + + switch (event.data.type) { + case 'cancel': { + const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined; + if (!url) { + return; + } + + const client = event.source; + if (!client) { + return; + } + + handleCancel(url); + break; + } + } + }); +}; diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts index aeb63be899..5fdf7f82c1 100644 --- a/web/src/service-worker/request.ts +++ b/web/src/service-worker/request.ts @@ -1,73 +1,69 @@ -import { get, put } from './cache'; +/// +/// +/// +/// -const pendingRequests = new Map(); - -const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; -const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; - -const assertResponse = (response: Response) => { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); - } +type PendingRequest = { + controller: AbortController; + promise: Promise; + cleanupTimeout?: ReturnType; }; -const getCacheKey = (request: URL | Request) => { - if (isURL(request)) { - return request.toString(); +const pendingRequests = new Map(); + +const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url); + +const CANCELATION_MESSAGE = 'Request canceled by application'; +const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export const handleFetch = (request: URL | Request): Promise => { + const requestKey = getRequestKey(request); + const existing = pendingRequests.get(requestKey); + + if (existing) { + // Clone the response since response bodies can only be read once + // Each caller gets an independent clone they can consume + return existing.promise.then((response) => response.clone()); } - if (isRequest(request)) { - return request.url; - } + const pendingRequest: PendingRequest = { + controller: new AbortController(), + promise: undefined as unknown as Promise, + cleanupTimeout: undefined, + }; + pendingRequests.set(requestKey, pendingRequest); - throw new Error(`Invalid request: ${request}`); -}; + // NOTE: fetch returns after headers received, not the body + pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal }) + .catch((error: unknown) => { + const standardError = error instanceof Error ? error : new Error(String(error)); + if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) { + // dummy response avoids network errors in the console for these requests + return new Response(undefined, { status: 204 }); + } + throw standardError; + }) + .finally(() => { + // Schedule cleanup after timeout to allow response body streaming to complete + const cleanupTimeout = setTimeout(() => { + pendingRequests.delete(requestKey); + }, CLEANUP_TIMEOUT_MS); + pendingRequest.cleanupTimeout = cleanupTimeout; + }); -export const handlePreload = async (request: URL | Request) => { - try { - return await handleRequest(request); - } catch (error) { - console.error(`Preload failed: ${error}`); - } -}; - -export const handleRequest = async (request: URL | Request) => { - const cacheKey = getCacheKey(request); - const cachedResponse = await get(cacheKey); - if (cachedResponse) { - return cachedResponse; - } - - try { - const cancelToken = new AbortController(); - pendingRequests.set(cacheKey, cancelToken); - const response = await fetch(request, { signal: cancelToken.signal }); - - assertResponse(response); - put(cacheKey, response); - - return response; - } catch (error) { - if (error.name === 'AbortError') { - // dummy response avoids network errors in the console for these requests - return new Response(undefined, { status: 204 }); - } - - console.log('Not an abort error', error); - - throw error; - } finally { - pendingRequests.delete(cacheKey); - } + // Clone for the first caller to keep the original response unconsumed for future callers + return pendingRequest.promise.then((response) => response.clone()); }; export const handleCancel = (url: URL) => { - const cacheKey = getCacheKey(url); - const pendingRequest = pendingRequests.get(cacheKey); - if (!pendingRequest) { - return; - } + const requestKey = getRequestKey(url); - pendingRequest.abort(); - pendingRequests.delete(cacheKey); + const pendingRequest = pendingRequests.get(requestKey); + if (pendingRequest) { + pendingRequest.controller.abort(CANCELATION_MESSAGE); + if (pendingRequest.cleanupTimeout) { + clearTimeout(pendingRequest.cleanupTimeout); + } + pendingRequests.delete(requestKey); + } }; diff --git a/web/svelte.config.js b/web/svelte.config.js index 5daf958986..e2a5fb5c46 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -2,7 +2,7 @@ import adapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import dotenv from 'dotenv'; -dotenv.config(); +dotenv.config({ quiet: true }); process.env.PUBLIC_IMMICH_BUY_HOST = process.env.PUBLIC_IMMICH_BUY_HOST || 'https://buy.immich.app'; process.env.PUBLIC_IMMICH_PAY_HOST = process.env.PUBLIC_IMMICH_PAY_HOST || 'https://pay.futo.org';