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..2aaf73ef22 --- /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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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..849957ae36 100644 --- a/cli/package.json +++ b/cli/package.json @@ -13,23 +13,23 @@ "cli" ], "devDependencies": { - "@eslint/js": "^9.8.0", - "@immich/sdk": "file:../open-api/typescript-sdk", + "@eslint/js": "^10.0.0", + "@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", "commander": "^12.0.0", - "eslint": "^9.14.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^62.0.0", - "globals": "^16.0.0", + "eslint-plugin-unicorn": "^63.0.0", + "globals": "^17.0.0", "mock-fs": "^5.2.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.0.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 42c33491f2..7d4b09b69d 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -17,7 +17,7 @@ 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'; @@ -403,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'); @@ -429,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; @@ -474,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/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 81fc492001..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: @@ -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/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/libraries.md b/docs/docs/features/libraries.md index 2fb5a1c56a..6e8246b06c 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -80,6 +80,10 @@ There is an automatic scan job that is scheduled to run once a day. Its schedule This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page. +### Deleting a Library + +When deleting an external library, all assets inside are immediately deleted along with the library. Note that while a library can take a long time to fully delete in the background, it is immediately removed from the library list. If the deletion process is interrupted (for example, due to server restart), it will be cleaned up in the next nightly cron job. The cleanup process can also be manually initiated by clicking the "Scan All Libraries" button in the library list. + ## Usage Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add: 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/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..ac1ae081b3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,37 +7,42 @@ "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": [], "author": "", "license": "GNU Affero General Public License version 3", "devDependencies": { - "@eslint/js": "^9.8.0", + "@eslint/js": "^10.0.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", "dotenv": "^17.2.3", - "eslint": "^9.14.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^62.0.0", - "exiftool-vendored": "^34.3.0", - "globals": "^16.0.0", + "eslint-plugin-unicorn": "^63.0.0", + "exiftool-vendored": "^35.0.0", + "globals": "^17.0.0", "luxon": "^3.4.4", "pg": "^8.11.3", "pngjs": "^7.0.0", @@ -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/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/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts index 017bc0fcb2..f6d1ec98d4 100644 --- a/e2e/src/specs/web/shared-link.e2e-spec.ts +++ b/e2e/src/specs/web/shared-link.e2e-spec.ts @@ -45,8 +45,7 @@ test.describe('Shared Links', () => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.locator(`[data-asset-id="${asset.id}"]`).hover(); - await page.waitForSelector('[data-group] svg'); - await page.getByRole('checkbox').click(); + await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`); await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]); }); diff --git a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts index 9408f6079a..6a7ce82672 100644 --- a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts +++ b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts @@ -438,7 +438,7 @@ test.describe('Timeline', () => { const asset = getAsset(timelineRestData, album.assetIds[0])!; await pageUtils.goToAsset(page, asset.fileCreatedAt); await thumbnailUtils.expectInViewport(page, asset.id); - await thumbnailUtils.expectSelectedReadonly(page, asset.id); + await thumbnailUtils.expectSelectedDisabled(page, asset.id); }); test('Add photos to album', async ({ page }) => { const album = timelineRestData.album; @@ -447,7 +447,7 @@ test.describe('Timeline', () => { const asset = getAsset(timelineRestData, album.assetIds[0])!; await pageUtils.goToAsset(page, asset.fileCreatedAt); await thumbnailUtils.expectInViewport(page, asset.id); - await thumbnailUtils.expectSelectedReadonly(page, asset.id); + await thumbnailUtils.expectSelectedDisabled(page, asset.id); await pageUtils.selectDay(page, 'Tue, Feb 27, 2024'); const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => { const requestJson = request.postDataJSON(); diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index 774839b174..d3e4e5f7ec 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(); @@ -102,12 +102,9 @@ export const thumbnailUtils = { async expectThumbnailIsNotArchive(page: Page, assetId: string) { 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 + async expectSelectedDisabled(page: Page, assetId: string) { 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][data-disabled]`), ).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/en.json b/i18n/en.json index 93a4fc57c6..01b1027b7e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1074,6 +1074,7 @@ "failed_to_update_notification_status": "Failed to update notification status", "incorrect_email_or_password": "Incorrect email or password", "library_folder_already_exists": "This import path already exists.", + "page_not_found": "Page not found :/", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "quota_higher_than_disk_size": "You set a quota higher than the disk size", @@ -1218,6 +1219,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", @@ -1946,6 +1948,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", @@ -2025,6 +2028,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", @@ -2303,6 +2309,7 @@ "unstack_action_prompt": "{count} unstacked", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "unsupported_field_type": "Unsupported field type", + "unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.", "untagged": "Untagged", "untitled_workflow": "Untitled workflow", "up_next": "Up next", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index e3d24ce172..c43d0df2cc 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -8,7 +8,6 @@ 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", diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 25f59a8fe5..1540d391e4 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -654,18 +654,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/25/fab23259a52ece5670dcb8452e1af34b89e6135ecc17cd4b54b4b479eac6/fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960", size = 168979, upload-time = "2023-12-11T21:19:52.446Z" }, ] -[[package]] -name = "ftfy" -version = "6.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/d3/8650919bc3c7c6e90ee3fa7fd618bf373cbbe55dff043bd67353dbb20cd8/ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec", size = 308927, upload-time = "2024-10-26T00:50:35.149Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" }, -] - [[package]] name = "gevent" version = "24.10.3" @@ -788,14 +776,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "23.0.0" +version = "25.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" }, ] [[package]] @@ -939,7 +927,6 @@ source = { editable = "." } dependencies = [ { name = "aiocache" }, { name = "fastapi" }, - { name = "ftfy" }, { name = "gunicorn" }, { name = "huggingface-hub" }, { name = "insightface" }, @@ -1018,7 +1005,6 @@ types = [ requires-dist = [ { name = "aiocache", specifier = ">=0.12.1,<1.0" }, { name = "fastapi", specifier = ">=0.95.2,<1.0" }, - { name = "ftfy", specifier = ">=6.1.1" }, { name = "gunicorn", specifier = ">=21.1.0" }, { name = "huggingface-hub", specifier = ">=0.20.1,<1.0" }, { name = "insightface", specifier = ">=0.7.3,<1.0" }, diff --git a/mise.toml b/mise.toml index 3ca0d353ea..a87b1c3a29 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.2" +pnpm = "10.30.0" 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/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/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_v20.json b/mobile/drift_schemas/main/drift_schema_v20.json new file mode 100644 index 0000000000..f85af83439 --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v20.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":[]},{"name":"is_visible","getter_name":"isVisible","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_visible\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_visible\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","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":[]}],"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/constants/enums.dart b/mobile/lib/constants/enums.dart index 350f6b80fa..32ef9bbbed 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -18,3 +18,5 @@ enum ActionSource { timeline, viewer } enum CleanupStep { selectDate, scan, delete } enum AssetKeepType { none, photosOnly, videosOnly } + +enum AssetDateAggregation { start, end } 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 a657fe333f..1cc8b98af9 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -73,9 +73,12 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), + // Image viewer navigation settings + tapToNavigate._(141), + // Map custom time range settings - mapCustomFrom._(141), - mapCustomTo._(142), + mapCustomFrom._(142), + mapCustomTo._(143), // Experimental stuff photoManagerCustomFilter._(1000), 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/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 0cf3f3e1c1..945ba8eb3f 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -43,8 +43,8 @@ class RemoteAlbumService { AlbumSortMode.title => albums.sortedBy((album) => album.name), AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), - AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), - AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), + AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end), + AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start), }; final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder; @@ -172,46 +172,25 @@ class RemoteAlbumService { return _repository.getAlbumsContainingAsset(assetId); } - Future> _sortByNewestAsset(List albums) async { - // map album IDs to their newest asset dates - final Map> assetTimestampFutures = {}; - for (final album in albums) { - assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id); + Future> _sortByAssetDate( + List albums, { + required AssetDateAggregation aggregation, + }) async { + if (albums.isEmpty) return []; + + final albumIds = albums.map((e) => e.id).toList(); + final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation); + + final albumMap = Map.fromEntries(albums.map((a) => MapEntry(a.id, a))); + + final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType().toList(); + + if (sortedAlbums.length < albums.length) { + final returnedIdSet = sortedIds.toSet(); + final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id)); + sortedAlbums.addAll(emptyAlbums); } - // await all database queries - final entries = await Future.wait( - assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)), - ); - final assetTimestamps = Map.fromEntries(entries); - - final sorted = albums.sorted((a, b) { - final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - return aDate.compareTo(bDate); - }); - - return sorted; - } - - Future> _sortByOldestAsset(List albums) async { - // map album IDs to their oldest asset dates - final Map> assetTimestampFutures = { - for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id), - }; - - // await all database queries - final entries = await Future.wait( - assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)), - ); - final assetTimestamps = Map.fromEntries(entries); - - final sorted = albums.sorted((a, b) { - final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - return aDate.compareTo(bDate); - }); - - return sorted; + return sortedAlbums; } } diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index af1c94ca71..2bda6cd683 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -68,12 +68,12 @@ class SyncStreamService { return false; } - final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_); + final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_); final value = Store.get(StoreKey.syncMigrationStatus, "[]"); final migrations = (jsonDecode(value) as List).cast(); int previousLength = migrations.length; - await _runPreSyncTasks(migrations, semVer); + await _runPreSyncTasks(migrations, serverSemVer); if (migrations.length != previousLength) { _logger.info("Updated pre-sync migration status: $migrations"); @@ -82,10 +82,14 @@ class SyncStreamService { // Start the sync stream and handle events bool shouldReset = false; - await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true); + await _syncApiRepository.streamChanges( + _handleEvents, + serverVersion: serverSemVer, + onReset: () => shouldReset = true, + ); if (shouldReset) { _logger.info("Resetting sync state as requested by server"); - await _syncApiRepository.streamChanges(_handleEvents); + await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer); } previousLength = migrations.length; @@ -282,6 +286,8 @@ class SyncStreamService { return _syncStreamRepository.deletePeopleV1(data.cast()); case SyncEntityType.assetFaceV1: return _syncStreamRepository.updateAssetFacesV1(data.cast()); + case SyncEntityType.assetFaceV2: + return _syncStreamRepository.updateAssetFacesV2(data.cast()); case SyncEntityType.assetFaceDeleteV1: return _syncStreamRepository.deleteAssetFacesV1(data.cast()); default: 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 45a0b436bd..40fe9ab1c1 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.dart @@ -28,6 +28,10 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin { TextColumn get sourceType => text()(); + BoolColumn get isVisible => boolean().withDefault(const Constant(true))(); + + DateTimeColumn get deletedAt => dateTime().nullable()(); + @override Set get primaryKey => {id}; } diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart index 7f2f3825e3..c97dd545a8 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart @@ -5,11 +5,12 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da as i1; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart' as i2; +import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' - as i3; -import 'package:drift/internal/modular.dart' as i4; + as i4; +import 'package:drift/internal/modular.dart' as i5; import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart' - as i5; + as i6; typedef $$AssetFaceEntityTableCreateCompanionBuilder = i1.AssetFaceEntityCompanion Function({ @@ -23,6 +24,8 @@ typedef $$AssetFaceEntityTableCreateCompanionBuilder = required int boundingBoxX2, required int boundingBoxY2, required String sourceType, + i0.Value isVisible, + i0.Value deletedAt, }); typedef $$AssetFaceEntityTableUpdateCompanionBuilder = i1.AssetFaceEntityCompanion Function({ @@ -36,6 +39,8 @@ typedef $$AssetFaceEntityTableUpdateCompanionBuilder = i0.Value boundingBoxX2, i0.Value boundingBoxY2, i0.Value sourceType, + i0.Value isVisible, + i0.Value deletedAt, }); final class $$AssetFaceEntityTableReferences @@ -51,29 +56,29 @@ final class $$AssetFaceEntityTableReferences super.$_typedResult, ); - static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => - i4.ReadDatabaseContainer(db) - .resultSet('remote_asset_entity') + static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i5.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') .createAlias( i0.$_aliasNameGenerator( - i4.ReadDatabaseContainer(db) + i5.ReadDatabaseContainer(db) .resultSet('asset_face_entity') .assetId, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( db, - ).resultSet('remote_asset_entity').id, + ).resultSet('remote_asset_entity').id, ), ); - i3.$$RemoteAssetEntityTableProcessedTableManager get assetId { + i4.$$RemoteAssetEntityTableProcessedTableManager get assetId { final $_column = $_itemColumn('asset_id')!; - final manager = i3 + final manager = i4 .$$RemoteAssetEntityTableTableManager( $_db, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( $_db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), ) .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); @@ -83,29 +88,29 @@ final class $$AssetFaceEntityTableReferences ); } - static i5.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) => - i4.ReadDatabaseContainer(db) - .resultSet('person_entity') + static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) => + i5.ReadDatabaseContainer(db) + .resultSet('person_entity') .createAlias( i0.$_aliasNameGenerator( - i4.ReadDatabaseContainer(db) + i5.ReadDatabaseContainer(db) .resultSet('asset_face_entity') .personId, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( db, - ).resultSet('person_entity').id, + ).resultSet('person_entity').id, ), ); - i5.$$PersonEntityTableProcessedTableManager? get personId { + i6.$$PersonEntityTableProcessedTableManager? get personId { final $_column = $_itemColumn('person_id'); if ($_column == null) return null; - final manager = i5 + final manager = i6 .$$PersonEntityTableTableManager( $_db, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( $_db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), ) .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_personIdTable($_db)); @@ -165,24 +170,34 @@ class $$AssetFaceEntityTableFilterComposer builder: (column) => i0.ColumnFilters(column), ); - i3.$$RemoteAssetEntityTableFilterComposer get assetId { - final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( + i0.ColumnFilters get isVisible => $composableBuilder( + column: $table.isVisible, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i4.$$RemoteAssetEntityTableFilterComposer get assetId { + final i4.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.assetId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i3.$$RemoteAssetEntityTableFilterComposer( + }) => i4.$$RemoteAssetEntityTableFilterComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -192,24 +207,24 @@ class $$AssetFaceEntityTableFilterComposer return composer; } - i5.$$PersonEntityTableFilterComposer get personId { - final i5.$$PersonEntityTableFilterComposer composer = $composerBuilder( + i6.$$PersonEntityTableFilterComposer get personId { + final i6.$$PersonEntityTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.personId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i5.$$PersonEntityTableFilterComposer( + }) => i6.$$PersonEntityTableFilterComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -269,25 +284,35 @@ class $$AssetFaceEntityTableOrderingComposer builder: (column) => i0.ColumnOrderings(column), ); - i3.$$RemoteAssetEntityTableOrderingComposer get assetId { - final i3.$$RemoteAssetEntityTableOrderingComposer composer = + i0.ColumnOrderings get isVisible => $composableBuilder( + column: $table.isVisible, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i4.$$RemoteAssetEntityTableOrderingComposer get assetId { + final i4.$$RemoteAssetEntityTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.assetId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i3.$$RemoteAssetEntityTableOrderingComposer( + }) => i4.$$RemoteAssetEntityTableOrderingComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -297,24 +322,24 @@ class $$AssetFaceEntityTableOrderingComposer return composer; } - i5.$$PersonEntityTableOrderingComposer get personId { - final i5.$$PersonEntityTableOrderingComposer composer = $composerBuilder( + i6.$$PersonEntityTableOrderingComposer get personId { + final i6.$$PersonEntityTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.personId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i5.$$PersonEntityTableOrderingComposer( + }) => i6.$$PersonEntityTableOrderingComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -372,25 +397,31 @@ class $$AssetFaceEntityTableAnnotationComposer builder: (column) => column, ); - i3.$$RemoteAssetEntityTableAnnotationComposer get assetId { - final i3.$$RemoteAssetEntityTableAnnotationComposer composer = + i0.GeneratedColumn get isVisible => + $composableBuilder(column: $table.isVisible, builder: (column) => column); + + i0.GeneratedColumn get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => column); + + i4.$$RemoteAssetEntityTableAnnotationComposer get assetId { + final i4.$$RemoteAssetEntityTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.assetId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i3.$$RemoteAssetEntityTableAnnotationComposer( + }) => i4.$$RemoteAssetEntityTableAnnotationComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -400,24 +431,24 @@ class $$AssetFaceEntityTableAnnotationComposer return composer; } - i5.$$PersonEntityTableAnnotationComposer get personId { - final i5.$$PersonEntityTableAnnotationComposer composer = $composerBuilder( + i6.$$PersonEntityTableAnnotationComposer get personId { + final i6.$$PersonEntityTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.personId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i5.$$PersonEntityTableAnnotationComposer( + }) => i6.$$PersonEntityTableAnnotationComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -468,6 +499,8 @@ class $$AssetFaceEntityTableTableManager i0.Value boundingBoxX2 = const i0.Value.absent(), i0.Value boundingBoxY2 = const i0.Value.absent(), i0.Value sourceType = const i0.Value.absent(), + i0.Value isVisible = const i0.Value.absent(), + i0.Value deletedAt = const i0.Value.absent(), }) => i1.AssetFaceEntityCompanion( id: id, assetId: assetId, @@ -479,6 +512,8 @@ class $$AssetFaceEntityTableTableManager boundingBoxX2: boundingBoxX2, boundingBoxY2: boundingBoxY2, sourceType: sourceType, + isVisible: isVisible, + deletedAt: deletedAt, ), createCompanionCallback: ({ @@ -492,6 +527,8 @@ class $$AssetFaceEntityTableTableManager required int boundingBoxX2, required int boundingBoxY2, required String sourceType, + i0.Value isVisible = const i0.Value.absent(), + i0.Value deletedAt = const i0.Value.absent(), }) => i1.AssetFaceEntityCompanion.insert( id: id, assetId: assetId, @@ -503,6 +540,8 @@ class $$AssetFaceEntityTableTableManager boundingBoxX2: boundingBoxX2, boundingBoxY2: boundingBoxY2, sourceType: sourceType, + isVisible: isVisible, + deletedAt: deletedAt, ), withReferenceMapper: (p0) => p0 .map( @@ -709,6 +748,33 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity type: i0.DriftSqlType.string, requiredDuringInsert: true, ); + static const i0.VerificationMeta _isVisibleMeta = const i0.VerificationMeta( + 'isVisible', + ); + @override + late final i0.GeneratedColumn isVisible = i0.GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const i3.Constant(true), + ); + static const i0.VerificationMeta _deletedAtMeta = const i0.VerificationMeta( + 'deletedAt', + ); + @override + late final i0.GeneratedColumn deletedAt = + i0.GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -721,6 +787,8 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity boundingBoxX2, boundingBoxY2, sourceType, + isVisible, + deletedAt, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -824,6 +892,18 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity } else if (isInserting) { context.missing(_sourceTypeMeta); } + if (data.containsKey('is_visible')) { + context.handle( + _isVisibleMeta, + isVisible.isAcceptableOrUnknown(data['is_visible']!, _isVisibleMeta), + ); + } + if (data.containsKey('deleted_at')) { + context.handle( + _deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), + ); + } return context; } @@ -873,6 +953,14 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity i0.DriftSqlType.string, data['${effectivePrefix}source_type'], )!, + isVisible: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}is_visible'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), ); } @@ -899,6 +987,8 @@ class AssetFaceEntityData extends i0.DataClass final int boundingBoxX2; final int boundingBoxY2; final String sourceType; + final bool isVisible; + final DateTime? deletedAt; const AssetFaceEntityData({ required this.id, required this.assetId, @@ -910,6 +1000,8 @@ class AssetFaceEntityData extends i0.DataClass required this.boundingBoxX2, required this.boundingBoxY2, required this.sourceType, + required this.isVisible, + this.deletedAt, }); @override Map toColumns(bool nullToAbsent) { @@ -926,6 +1018,10 @@ class AssetFaceEntityData extends i0.DataClass map['bounding_box_x2'] = i0.Variable(boundingBoxX2); map['bounding_box_y2'] = i0.Variable(boundingBoxY2); map['source_type'] = i0.Variable(sourceType); + map['is_visible'] = i0.Variable(isVisible); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = i0.Variable(deletedAt); + } return map; } @@ -945,6 +1041,8 @@ class AssetFaceEntityData extends i0.DataClass boundingBoxX2: serializer.fromJson(json['boundingBoxX2']), boundingBoxY2: serializer.fromJson(json['boundingBoxY2']), sourceType: serializer.fromJson(json['sourceType']), + isVisible: serializer.fromJson(json['isVisible']), + deletedAt: serializer.fromJson(json['deletedAt']), ); } @override @@ -961,6 +1059,8 @@ class AssetFaceEntityData extends i0.DataClass 'boundingBoxX2': serializer.toJson(boundingBoxX2), 'boundingBoxY2': serializer.toJson(boundingBoxY2), 'sourceType': serializer.toJson(sourceType), + 'isVisible': serializer.toJson(isVisible), + 'deletedAt': serializer.toJson(deletedAt), }; } @@ -975,6 +1075,8 @@ class AssetFaceEntityData extends i0.DataClass int? boundingBoxX2, int? boundingBoxY2, String? sourceType, + bool? isVisible, + i0.Value deletedAt = const i0.Value.absent(), }) => i1.AssetFaceEntityData( id: id ?? this.id, assetId: assetId ?? this.assetId, @@ -986,6 +1088,8 @@ class AssetFaceEntityData extends i0.DataClass boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, ); AssetFaceEntityData copyWithCompanion(i1.AssetFaceEntityCompanion data) { return AssetFaceEntityData( @@ -1013,6 +1117,8 @@ class AssetFaceEntityData extends i0.DataClass sourceType: data.sourceType.present ? data.sourceType.value : this.sourceType, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, ); } @@ -1028,7 +1134,9 @@ class AssetFaceEntityData extends i0.DataClass ..write('boundingBoxY1: $boundingBoxY1, ') ..write('boundingBoxX2: $boundingBoxX2, ') ..write('boundingBoxY2: $boundingBoxY2, ') - ..write('sourceType: $sourceType') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') ..write(')')) .toString(); } @@ -1045,6 +1153,8 @@ class AssetFaceEntityData extends i0.DataClass boundingBoxX2, boundingBoxY2, sourceType, + isVisible, + deletedAt, ); @override bool operator ==(Object other) => @@ -1059,7 +1169,9 @@ class AssetFaceEntityData extends i0.DataClass other.boundingBoxY1 == this.boundingBoxY1 && other.boundingBoxX2 == this.boundingBoxX2 && other.boundingBoxY2 == this.boundingBoxY2 && - other.sourceType == this.sourceType); + other.sourceType == this.sourceType && + other.isVisible == this.isVisible && + other.deletedAt == this.deletedAt); } class AssetFaceEntityCompanion @@ -1074,6 +1186,8 @@ class AssetFaceEntityCompanion final i0.Value boundingBoxX2; final i0.Value boundingBoxY2; final i0.Value sourceType; + final i0.Value isVisible; + final i0.Value deletedAt; const AssetFaceEntityCompanion({ this.id = const i0.Value.absent(), this.assetId = const i0.Value.absent(), @@ -1085,6 +1199,8 @@ class AssetFaceEntityCompanion this.boundingBoxX2 = const i0.Value.absent(), this.boundingBoxY2 = const i0.Value.absent(), this.sourceType = const i0.Value.absent(), + this.isVisible = const i0.Value.absent(), + this.deletedAt = const i0.Value.absent(), }); AssetFaceEntityCompanion.insert({ required String id, @@ -1097,6 +1213,8 @@ class AssetFaceEntityCompanion required int boundingBoxX2, required int boundingBoxY2, required String sourceType, + this.isVisible = const i0.Value.absent(), + this.deletedAt = const i0.Value.absent(), }) : id = i0.Value(id), assetId = i0.Value(assetId), imageWidth = i0.Value(imageWidth), @@ -1117,6 +1235,8 @@ class AssetFaceEntityCompanion i0.Expression? boundingBoxX2, i0.Expression? boundingBoxY2, i0.Expression? sourceType, + i0.Expression? isVisible, + i0.Expression? deletedAt, }) { return i0.RawValuesInsertable({ if (id != null) 'id': id, @@ -1129,6 +1249,8 @@ class AssetFaceEntityCompanion if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2, if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2, if (sourceType != null) 'source_type': sourceType, + if (isVisible != null) 'is_visible': isVisible, + if (deletedAt != null) 'deleted_at': deletedAt, }); } @@ -1143,6 +1265,8 @@ class AssetFaceEntityCompanion i0.Value? boundingBoxX2, i0.Value? boundingBoxY2, i0.Value? sourceType, + i0.Value? isVisible, + i0.Value? deletedAt, }) { return i1.AssetFaceEntityCompanion( id: id ?? this.id, @@ -1155,6 +1279,8 @@ class AssetFaceEntityCompanion boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt ?? this.deletedAt, ); } @@ -1191,6 +1317,12 @@ class AssetFaceEntityCompanion if (sourceType.present) { map['source_type'] = i0.Variable(sourceType.value); } + if (isVisible.present) { + map['is_visible'] = i0.Variable(isVisible.value); + } + if (deletedAt.present) { + map['deleted_at'] = i0.Variable(deletedAt.value); + } return map; } @@ -1206,7 +1338,9 @@ class AssetFaceEntityCompanion ..write('boundingBoxY1: $boundingBoxY1, ') ..write('boundingBoxX2: $boundingBoxX2, ') ..write('boundingBoxY2: $boundingBoxY2, ') - ..write('sourceType: $sourceType') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') ..write(')')) .toString(); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 5495d21bd3..2d90044aea 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 => 19; + int get schemaVersion => 20; @override MigrationStrategy get migration => MigrationStrategy( @@ -226,6 +226,10 @@ class Drift extends $Drift implements IDatabaseRepository { await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth); await m.createIndex(v19.idxStackPrimaryAssetId); }, + from19To20: (m, v20) async { + await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible); + await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index e56eb97c75..527b0693c7 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -8360,6 +8360,550 @@ final class Schema19 extends i0.VersionedSchema { ); } +final class Schema20 extends i0.VersionedSchema { + Schema20({required super.database}) : super(version: 20); + @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 Shape29 assetFaceEntity = Shape29( + 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, + _column_102, + _column_18, + ], + 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)', + ); +} + +class Shape29 extends i0.VersionedTable { + Shape29({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get assetId => + columnsByName['asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get personId => + columnsByName['person_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get imageWidth => + columnsByName['image_width']! as i1.GeneratedColumn; + i1.GeneratedColumn get imageHeight => + columnsByName['image_height']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxX1 => + columnsByName['bounding_box_x1']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxY1 => + columnsByName['bounding_box_y1']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxX2 => + columnsByName['bounding_box_x2']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxY2 => + columnsByName['bounding_box_y2']! as i1.GeneratedColumn; + i1.GeneratedColumn get sourceType => + columnsByName['source_type']! as i1.GeneratedColumn; + i1.GeneratedColumn get isVisible => + columnsByName['is_visible']! as i1.GeneratedColumn; + i1.GeneratedColumn get deletedAt => + columnsByName['deleted_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_102(String aliasedName) => + i1.GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -8379,6 +8923,7 @@ i0.MigrationStepWithVersion migrationSteps({ 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, + required Future Function(i1.Migrator m, Schema20 schema) from19To20, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -8472,6 +9017,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from18To19(migrator, schema); return 19; + case 19: + final schema = Schema20(database: database); + final migrator = i1.Migrator(database, schema); + await from19To20(migrator, schema); + return 20; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -8497,6 +9047,7 @@ i1.OnUpgrade stepByStep({ 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, + required Future Function(i1.Migrator m, Schema20 schema) from19To20, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -8517,5 +9068,6 @@ i1.OnUpgrade stepByStep({ from16To17: from16To17, from17To18: from17To18, from18To19: from18To19, + from19To20: from19To20, ), ); diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 9d7cbd831b..6f6ef20aeb 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -184,7 +184,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } if (keepFavorites) { - whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false); + whereClause = + whereClause & _db.localAssetEntity.isFavorite.equals(false) & _db.remoteAssetEntity.isFavorite.equals(false); } query.where(whereClause); diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 40402b6f72..9e55d44867 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -16,9 +16,15 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } Future> getAssetPeople(String assetId) async { - final query = _db.select(_db.assetFaceEntity).join([ - innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)), - ])..where(_db.assetFaceEntity.assetId.equals(assetId) & _db.personEntity.isHidden.equals(false)); + final query = + _db.select(_db.assetFaceEntity).join([ + innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)), + ])..where( + _db.assetFaceEntity.assetId.equals(assetId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull() & + _db.personEntity.isHidden.equals(false), + ); return query.map((row) { final person = row.readTable(_db.personEntity); @@ -39,7 +45,9 @@ class DriftPeopleRepository extends DriftDatabaseRepository { ..where( people.isHidden.equals(false) & assets.deletedAt.isNull() & - assets.visibility.equalsValue(AssetVisibility.timeline), + assets.visibility.equalsValue(AssetVisibility.timeline) & + faces.isVisible.equals(true) & + faces.deletedAt.isNull(), ) ..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not()) ..orderBy([ diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index d7d4a250ad..a594647f19 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:drift/drift.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -321,26 +323,32 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }).watchSingleOrNull(); } - Future getNewestAssetTimestamp(String albumId) { - final query = _db.remoteAlbumAssetEntity.selectOnly() - ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) - ..addColumns([_db.remoteAssetEntity.localDateTime.max()]) - ..join([ - innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), - ]); + Future> getSortedAlbumIds(List albumIds, {required AssetDateAggregation aggregation}) async { + if (albumIds.isEmpty) return []; - return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull(); - } + final jsonIds = jsonEncode(albumIds); + final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX'; - Future getOldestAssetTimestamp(String albumId) { - final query = _db.remoteAlbumAssetEntity.selectOnly() - ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) - ..addColumns([_db.remoteAssetEntity.localDateTime.min()]) - ..join([ - innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), - ]); + final rows = await _db + .customSelect( + ''' + SELECT + raae.album_id, + $sqlAgg(rae.local_date_time) AS asset_date + FROM json_each(?) ids + INNER JOIN remote_album_asset_entity raae + ON raae.album_id = ids.value + INNER JOIN remote_asset_entity rae + ON rae.id = raae.asset_id + GROUP BY raae.album_id + ORDER BY asset_date ASC + ''', + variables: [Variable(jsonIds)], + readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity}, + ) + .get(); - return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull(); + return rows.map((row) => row.read('album_id')).toList(); } Future getCount() { 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/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index d13083d706..0e5c99edd7 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/semver.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -25,6 +26,7 @@ class SyncApiRepository { Future streamChanges( Future Function(List, Function() abort, Function() reset) onData, { + required SemVer serverVersion, Function()? onReset, int batchSize = kSyncEventBatchSize, http.Client? httpClient, @@ -64,7 +66,8 @@ class SyncApiRepository { SyncRequestType.partnerStacksV1, SyncRequestType.userMetadataV1, SyncRequestType.peopleV1, - SyncRequestType.assetFacesV1, + if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1, + if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2, ], reset: shouldReset, ).toJson(), @@ -190,6 +193,7 @@ const _kResponseMap = { SyncEntityType.personV1: SyncPersonV1.fromJson, SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson, SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson, + SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson, SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson, SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson, }; diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 26f89432a5..8ff1c2d59c 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -652,6 +652,37 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future updateAssetFacesV2(Iterable data) async { + try { + await _db.batch((batch) { + for (final assetFace in data) { + final companion = AssetFaceEntityCompanion( + assetId: Value(assetFace.assetId), + personId: Value(assetFace.personId), + imageWidth: Value(assetFace.imageWidth), + imageHeight: Value(assetFace.imageHeight), + boundingBoxX1: Value(assetFace.boundingBoxX1), + boundingBoxY1: Value(assetFace.boundingBoxY1), + boundingBoxX2: Value(assetFace.boundingBoxX2), + boundingBoxY2: Value(assetFace.boundingBoxY2), + sourceType: Value(assetFace.sourceType), + deletedAt: Value(assetFace.deletedAt), + isVisible: Value(assetFace.isVisible), + ); + + batch.insert( + _db.assetFaceEntity, + companion.copyWith(id: Value(assetFace.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetFacesV2', error, stack); + rethrow; + } + } + Future deleteAssetFacesV1(Iterable data) async { try { await _db.batch((batch) { 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 70b4701eb0..fdb332868b 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -326,6 +326,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive), groupBy: groupBy, origin: TimelineOrigin.archive, + joinLocal: true, ); TimelineQuery locked(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder( @@ -424,7 +425,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & - _db.assetFaceEntity.personId.equals(personId), + _db.assetFaceEntity.personId.equals(personId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull(), ); return query.map((row) { @@ -449,7 +452,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & - _db.assetFaceEntity.personId.equals(personId), + _db.assetFaceEntity.personId.equals(personId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull(), ) ..groupBy([dateExp]) ..orderBy([OrderingTerm.desc(dateExp)]); @@ -479,7 +484,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & - _db.assetFaceEntity.personId.equals(personId), + _db.assetFaceEntity.personId.equals(personId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull(), ) ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) ..limit(count, offset: offset); 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/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/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/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index c7d786626c..0d728422d1 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -109,9 +109,43 @@ class SplashScreenPageState extends ConsumerState { if (context.router.current.name == SplashScreenRoute.name) { final needBetaMigration = Store.get(StoreKey.needBetaMigration, false); if (needBetaMigration) { + bool migrate = + (await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("New Timeline Experience"), + content: const Text( + "The old timeline has been deprecated and will be removed in an upcoming release. Would you like to switch to the new timeline now?", + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")), + ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")), + ], + ), + )) ?? + false; + if (migrate != true) { + migrate = + (await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Are you sure?"), + content: const Text( + "If you choose to remain on the old timeline, you will be automatically migrated to the new timeline in an upcoming release. Would you like to switch now?", + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")), + ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")), + ], + ), + )) ?? + false; + } await Store.put(StoreKey.needBetaMigration, false); - unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)])); - return; + if (migrate) { + unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)])); + return; + } } unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute())); 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/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/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/delete_trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart index cb0e7091c8..0d9bc41734 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; /// This delete action has the following behavior: @@ -22,6 +23,18 @@ class DeleteTrashActionButton extends ConsumerWidget { return; } + final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length)); + + final confirmDelete = + await showDialog( + context: context, + builder: (context) => TrashDeleteDialog(count: selectCount), + ) ?? + false; + if (!confirmDelete) { + return; + } + final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); ref.read(multiSelectProvider.notifier).reset(); 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..686b3fcf10 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -0,0 +1,461 @@ +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; + + 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; + _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) { + _viewer.setShowingDetails(true); + } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { + _viewer.setShowingDetails(false); + } + } + + void _beginDrag(DragStartDetails details) { + _dragStart = details; + + 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/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 3c3ed460b4..c3cda46e81 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -48,7 +48,7 @@ mixin CancellableImageProviderMixin on CancellableImageProvide return null; } - Stream loadRequest(ImageRequest request, ImageDecoderCallback decode) async* { + Stream loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* { if (isCancelled) { this.request = null; PaintingBinding.instance.imageCache.evict(this); @@ -57,14 +57,19 @@ mixin CancellableImageProviderMixin on CancellableImageProvide try { final image = await request.load(decode); - if (image == null || isCancelled) { + if ((image == null && evictOnError) || isCancelled) { PaintingBinding.instance.imageCache.evict(this); return; + } else if (image == null) { + return; } yield image; - } catch (e) { - PaintingBinding.instance.imageCache.evict(this); - rethrow; + } catch (e, stack) { + if (evictOnError) { + PaintingBinding.instance.imageCache.evict(this); + rethrow; + } + _log.warning('Non-fatal image load error', e, stack); } finally { this.request = null; } diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 03b9370190..1c7d102239 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -94,7 +94,6 @@ class LocalFullImageProvider extends CancellableImageProvider ProviderScope( overrides: [ - timelineArgsProvider.overrideWithValue( - TimelineArgs( + timelineArgsProvider.overrideWith( + (ref) => TimelineArgs( maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight, columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), @@ -78,14 +80,15 @@ class Timeline extends ConsumerWidget { if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), ], child: _SliverTimeline( - key: const ValueKey('_sliver_timeline'), topSliverWidget: topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight, appBar: appBar, bottomSheet: bottomSheet, withScrubber: withScrubber, + persistentBottomBar: persistentBottomBar, snapToMonth: snapToMonth, initialScrollOffset: initialScrollOffset, + maxWidth: constraints.maxWidth, ), ), ), @@ -106,14 +109,15 @@ 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, + this.maxWidth, }); final Widget? topSliverWidget; @@ -121,8 +125,10 @@ class _SliverTimeline extends ConsumerStatefulWidget { final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; + final bool persistentBottomBar; final bool snapToMonth; final double? initialScrollOffset; + final double? maxWidth; @override ConsumerState createState() => _SliverTimelineState(); @@ -160,6 +166,20 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled); } + @override + void didUpdateWidget(covariant _SliverTimeline oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.maxWidth != oldWidget.maxWidth) { + final asyncSegments = ref.read(timelineSegmentProvider); + asyncSegments.whenData((segments) { + final index = _getCurrentAssetIndex(segments); + // Refresh to wait for new segments to be generated with the updated width before restoring the scroll position + final _ = ref.refresh(timelineArgsProvider); + _restoreAssetIndex = index; + }); + } + } + void _onEvent(Event event) { switch (event) { case ScrollToTopEvent(): @@ -177,10 +197,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } } - void _onMultiSelectionToggled(_, bool isEnabled) { - EventStream.shared.emit(MultiSelectToggleEvent(isEnabled)); - } - void _restoreAssetPosition(_) { if (_restoreAssetIndex == null) return; @@ -203,6 +219,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _restoreAssetIndex = null; } + void _onMultiSelectionToggled(_, bool isEnabled) { + EventStream.shared.emit(MultiSelectToggleEvent(isEnabled)); + } + int? _getCurrentAssetIndex(List segments) { final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent); final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull; @@ -341,6 +361,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, @@ -407,66 +430,56 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return PrimaryScrollController( controller: _scrollController, - child: NotificationListener( - onNotification: (notification) { - final currentIndex = _getCurrentAssetIndex(segments); - if (currentIndex != null && mounted) { - _restoreAssetIndex = currentIndex; - } - return false; - }, - child: RawGestureDetector( - gestures: { - CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => CustomScaleGestureRecognizer(), - (CustomScaleGestureRecognizer scale) { - scale.onStart = (details) { - _baseScaleFactor = _scaleFactor; - }; + 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(); + 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 targetAssetIndex = _getCurrentAssetIndex(segments); + setState(() { + _scaleFactor = newScaleFactor; + _perRow = newPerRow; + _restoreAssetIndex = targetAssetIndex; + }); - if (newPerRow != _perRow) { - setState(() { - _scaleFactor = newScaleFactor; - _perRow = newPerRow; - _restoreAssetIndex = targetAssetIndex; - }); - - ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); - } - }; - }, - ), - }, - 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); + 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 (isBottomWidgetVisible) + 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/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 2bc000db45..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'; @@ -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 bdd897b2d9..8a20d526aa 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/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/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/utils/migration.dart b/mobile/lib/utils/migration.dart index 70f9ba88c7..30a46daa56 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -30,11 +30,10 @@ import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; - // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 21; +const int targetVersion = 22; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -100,6 +99,10 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { } } + if (version < 22 && !Store.isBetaTimelineEnabled) { + await Store.put(StoreKey.needBetaMigration, true); + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart new file mode 100644 index 0000000000..3470e8489e --- /dev/null +++ b/mobile/lib/utils/option.dart @@ -0,0 +1,58 @@ +sealed class Option { + const Option(); + + const factory Option.some(T value) = Some; + + const factory Option.none() = None; + + factory Option.fromNullable(T? value) => value != null ? Some(value) : None(); + + @pragma('vm:prefer-inline') + bool get isSome => this is Some; + + @pragma('vm:prefer-inline') + bool get isNone => this is None; + + @pragma('vm:prefer-inline') + T? get unwrapOrNull => switch (this) { + Some(:final value) => value, + None() => null, + }; + + U fold(U Function(T value) onSome, U Function() onNone) => switch (this) { + Some(:final value) => onSome(value), + None() => onNone(), + }; + + @override + String toString() => switch (this) { + Some(:final value) => 'Some($value)', + None() => 'None', + }; +} + +final class Some extends Option { + final T value; + + const Some(this.value); + + @override + bool operator ==(Object other) => other is Some && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class None extends Option { + const None(); + + @override + bool operator ==(Object other) => other is None; + + @override + int get hashCode => 0; +} + +extension ObjectOptionExtension on T? { + Option toOption() => Option.fromNullable(this); +} 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_grid/trash_delete_dialog.dart b/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart new file mode 100644 index 0000000000..eaca04b481 --- /dev/null +++ b/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class TrashDeleteDialog extends StatelessWidget { + const TrashDeleteDialog({super.key, required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + title: Text(context.t.permanently_delete), + content: ImmichFormattedText(context.t.permanently_delete_assets_prompt(count: count)), + actions: [ + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: () => context.pop(false), + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.surfaceDim, + foregroundColor: context.primaryColor, + ), + child: Text(context.t.cancel, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 48, + + child: FilledButton( + onPressed: () => context.pop(true), + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.errorContainer, + foregroundColor: context.colorScheme.onErrorContainer, + ), + child: Text(context.t.delete, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ); + } +} 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 527aae0e6e..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( @@ -154,15 +157,12 @@ class ImmichAppBarDialog extends HookConsumerWidget { } return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ - Text( - "backup_controller_page_server_storage", - style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500), - ).tr(), + Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge), LinearProgressIndicator( minHeight: 10.0, value: percentage, @@ -264,13 +264,13 @@ class ImmichAppBarDialog extends HookConsumerWidget { color: context.colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(10)), ), - margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + margin: const EdgeInsets.only(left: 12, right: 12, bottom: 8), child: Column( children: [ const AppBarProfileInfoBox(), - const Divider(height: 3), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), buildStorageInformation(), - const Divider(height: 3), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), const AppBarServerInfo(), ], ), 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 b0c005424f..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)); 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 3203b18df7..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 @@ -38,7 +38,7 @@ class AppBarServerInfo extends HookConsumerWidget { const divider = Divider(thickness: 1); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -109,7 +109,7 @@ class _ServerInfoItem extends StatelessWidget { style: TextStyle( fontSize: contentFontSize, color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), textAlign: TextAlign.end, 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/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index d6b516a078..e86d313294 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -135,7 +135,7 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_enable_alternate_media_filter_title".tr(), subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(), ), - const BetaTimelineListTile(), + if (!Store.isBetaTimelineEnabled) const BetaTimelineListTile(), if (Store.isBetaTimelineEnabled) SettingsSwitchListTile( valueNotifier: readonlyModeEnabled, 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/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/mise.toml b/mobile/mise.toml index 6767836aa3..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,7 +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" diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4ebe5c7c65..bb437787cb 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -358,12 +358,11 @@ Class | Method | HTTP request | Description - [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md) - [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md) - [AssetEditAction](doc//AssetEditAction.md) - - [AssetEditActionCrop](doc//AssetEditActionCrop.md) - - [AssetEditActionListDto](doc//AssetEditActionListDto.md) - - [AssetEditActionListDtoEditsInner](doc//AssetEditActionListDtoEditsInner.md) - - [AssetEditActionMirror](doc//AssetEditActionMirror.md) - - [AssetEditActionRotate](doc//AssetEditActionRotate.md) - - [AssetEditsDto](doc//AssetEditsDto.md) + - [AssetEditActionItemDto](doc//AssetEditActionItemDto.md) + - [AssetEditActionItemDtoParameters](doc//AssetEditActionItemDtoParameters.md) + - [AssetEditActionItemResponseDto](doc//AssetEditActionItemResponseDto.md) + - [AssetEditsCreateDto](doc//AssetEditsCreateDto.md) + - [AssetEditsResponseDto](doc//AssetEditsResponseDto.md) - [AssetFaceCreateDto](doc//AssetFaceCreateDto.md) - [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md) - [AssetFaceResponseDto](doc//AssetFaceResponseDto.md) @@ -416,6 +415,7 @@ Class | Method | HTTP request | Description - [DatabaseBackupDeleteDto](doc//DatabaseBackupDeleteDto.md) - [DatabaseBackupDto](doc//DatabaseBackupDto.md) - [DatabaseBackupListResponseDto](doc//DatabaseBackupListResponseDto.md) + - [DownloadArchiveDto](doc//DownloadArchiveDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) @@ -576,9 +576,12 @@ Class | Method | HTTP request | Description - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md) - [SyncAlbumV1](doc//SyncAlbumV1.md) - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) + - [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md) + - [SyncAssetEditV1](doc//SyncAssetEditV1.md) - [SyncAssetExifV1](doc//SyncAssetExifV1.md) - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md) - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md) + - [SyncAssetFaceV2](doc//SyncAssetFaceV2.md) - [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md) - [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f10490e093..253e8a6811 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -97,12 +97,11 @@ part 'model/asset_copy_dto.dart'; part 'model/asset_delta_sync_dto.dart'; part 'model/asset_delta_sync_response_dto.dart'; part 'model/asset_edit_action.dart'; -part 'model/asset_edit_action_crop.dart'; -part 'model/asset_edit_action_list_dto.dart'; -part 'model/asset_edit_action_list_dto_edits_inner.dart'; -part 'model/asset_edit_action_mirror.dart'; -part 'model/asset_edit_action_rotate.dart'; -part 'model/asset_edits_dto.dart'; +part 'model/asset_edit_action_item_dto.dart'; +part 'model/asset_edit_action_item_dto_parameters.dart'; +part 'model/asset_edit_action_item_response_dto.dart'; +part 'model/asset_edits_create_dto.dart'; +part 'model/asset_edits_response_dto.dart'; part 'model/asset_face_create_dto.dart'; part 'model/asset_face_delete_dto.dart'; part 'model/asset_face_response_dto.dart'; @@ -155,6 +154,7 @@ part 'model/database_backup_config.dart'; part 'model/database_backup_delete_dto.dart'; part 'model/database_backup_dto.dart'; part 'model/database_backup_list_response_dto.dart'; +part 'model/download_archive_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; @@ -315,9 +315,12 @@ part 'model/sync_album_user_delete_v1.dart'; part 'model/sync_album_user_v1.dart'; part 'model/sync_album_v1.dart'; part 'model/sync_asset_delete_v1.dart'; +part 'model/sync_asset_edit_delete_v1.dart'; +part 'model/sync_asset_edit_v1.dart'; part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_face_delete_v1.dart'; part 'model/sync_asset_face_v1.dart'; +part 'model/sync_asset_face_v2.dart'; part 'model/sync_asset_metadata_delete_v1.dart'; part 'model/sync_asset_metadata_v1.dart'; part 'model/sync_asset_v1.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 5fda01a594..a026b99028 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -421,14 +421,14 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetEditActionListDto] assetEditActionListDto (required): - Future editAssetWithHttpInfo(String id, AssetEditActionListDto assetEditActionListDto,) async { + /// * [AssetEditsCreateDto] assetEditsCreateDto (required): + Future editAssetWithHttpInfo(String id, AssetEditsCreateDto assetEditsCreateDto,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/edits' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetEditActionListDto; + Object? postBody = assetEditsCreateDto; final queryParams = []; final headerParams = {}; @@ -456,9 +456,9 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetEditActionListDto] assetEditActionListDto (required): - Future editAsset(String id, AssetEditActionListDto assetEditActionListDto,) async { - final response = await editAssetWithHttpInfo(id, assetEditActionListDto,); + /// * [AssetEditsCreateDto] assetEditsCreateDto (required): + Future editAsset(String id, AssetEditsCreateDto assetEditsCreateDto,) async { + final response = await editAssetWithHttpInfo(id, assetEditsCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -466,7 +466,7 @@ class AssetsApi { // 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), 'AssetEditsDto',) as AssetEditsDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsResponseDto',) as AssetEditsResponseDto; } return null; @@ -576,7 +576,7 @@ class AssetsApi { /// Parameters: /// /// * [String] id (required): - Future getAssetEdits(String id,) async { + Future getAssetEdits(String id,) async { final response = await getAssetEditsWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -585,7 +585,7 @@ class AssetsApi { // 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), 'AssetEditsDto',) as AssetEditsDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsResponseDto',) as AssetEditsResponseDto; } return null; 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_client.dart b/mobile/openapi/lib/api_client.dart index 470f3aec27..bfe469e7c0 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -240,18 +240,16 @@ class ApiClient { return AssetDeltaSyncResponseDto.fromJson(value); case 'AssetEditAction': return AssetEditActionTypeTransformer().decode(value); - case 'AssetEditActionCrop': - return AssetEditActionCrop.fromJson(value); - case 'AssetEditActionListDto': - return AssetEditActionListDto.fromJson(value); - case 'AssetEditActionListDtoEditsInner': - return AssetEditActionListDtoEditsInner.fromJson(value); - case 'AssetEditActionMirror': - return AssetEditActionMirror.fromJson(value); - case 'AssetEditActionRotate': - return AssetEditActionRotate.fromJson(value); - case 'AssetEditsDto': - return AssetEditsDto.fromJson(value); + case 'AssetEditActionItemDto': + return AssetEditActionItemDto.fromJson(value); + case 'AssetEditActionItemDtoParameters': + return AssetEditActionItemDtoParameters.fromJson(value); + case 'AssetEditActionItemResponseDto': + return AssetEditActionItemResponseDto.fromJson(value); + case 'AssetEditsCreateDto': + return AssetEditsCreateDto.fromJson(value); + case 'AssetEditsResponseDto': + return AssetEditsResponseDto.fromJson(value); case 'AssetFaceCreateDto': return AssetFaceCreateDto.fromJson(value); case 'AssetFaceDeleteDto': @@ -356,6 +354,8 @@ class ApiClient { return DatabaseBackupDto.fromJson(value); case 'DatabaseBackupListResponseDto': return DatabaseBackupListResponseDto.fromJson(value); + case 'DownloadArchiveDto': + return DownloadArchiveDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -676,12 +676,18 @@ class ApiClient { return SyncAlbumV1.fromJson(value); case 'SyncAssetDeleteV1': return SyncAssetDeleteV1.fromJson(value); + case 'SyncAssetEditDeleteV1': + return SyncAssetEditDeleteV1.fromJson(value); + case 'SyncAssetEditV1': + return SyncAssetEditV1.fromJson(value); case 'SyncAssetExifV1': return SyncAssetExifV1.fromJson(value); case 'SyncAssetFaceDeleteV1': return SyncAssetFaceDeleteV1.fromJson(value); case 'SyncAssetFaceV1': return SyncAssetFaceV1.fromJson(value); + case 'SyncAssetFaceV2': + return SyncAssetFaceV2.fromJson(value); case 'SyncAssetMetadataDeleteV1': return SyncAssetMetadataDeleteV1.fromJson(value); case 'SyncAssetMetadataV1': diff --git a/mobile/openapi/lib/model/asset_edit_action_crop.dart b/mobile/openapi/lib/model/asset_edit_action_crop.dart deleted file mode 100644 index 7672ed825b..0000000000 --- a/mobile/openapi/lib/model/asset_edit_action_crop.dart +++ /dev/null @@ -1,108 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class AssetEditActionCrop { - /// Returns a new [AssetEditActionCrop] instance. - AssetEditActionCrop({ - required this.action, - required this.parameters, - }); - - /// Type of edit action to perform - AssetEditAction action; - - CropParameters parameters; - - @override - bool operator ==(Object other) => identical(this, other) || other is AssetEditActionCrop && - other.action == action && - other.parameters == parameters; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (action.hashCode) + - (parameters.hashCode); - - @override - String toString() => 'AssetEditActionCrop[action=$action, parameters=$parameters]'; - - Map toJson() { - final json = {}; - json[r'action'] = this.action; - json[r'parameters'] = this.parameters; - return json; - } - - /// Returns a new [AssetEditActionCrop] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static AssetEditActionCrop? fromJson(dynamic value) { - upgradeDto(value, "AssetEditActionCrop"); - if (value is Map) { - final json = value.cast(); - - return AssetEditActionCrop( - action: AssetEditAction.fromJson(json[r'action'])!, - parameters: CropParameters.fromJson(json[r'parameters'])!, - ); - } - 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 = AssetEditActionCrop.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 = AssetEditActionCrop.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of AssetEditActionCrop-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] = AssetEditActionCrop.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'action', - 'parameters', - }; -} - diff --git a/mobile/openapi/lib/model/asset_edit_action_mirror.dart b/mobile/openapi/lib/model/asset_edit_action_item_dto.dart similarity index 59% rename from mobile/openapi/lib/model/asset_edit_action_mirror.dart rename to mobile/openapi/lib/model/asset_edit_action_item_dto.dart index aef98fc1a8..7829de4bd5 100644 --- a/mobile/openapi/lib/model/asset_edit_action_mirror.dart +++ b/mobile/openapi/lib/model/asset_edit_action_item_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class AssetEditActionMirror { - /// Returns a new [AssetEditActionMirror] instance. - AssetEditActionMirror({ +class AssetEditActionItemDto { + /// Returns a new [AssetEditActionItemDto] instance. + AssetEditActionItemDto({ required this.action, required this.parameters, }); @@ -20,10 +20,10 @@ class AssetEditActionMirror { /// Type of edit action to perform AssetEditAction action; - MirrorParameters parameters; + AssetEditActionItemDtoParameters parameters; @override - bool operator ==(Object other) => identical(this, other) || other is AssetEditActionMirror && + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDto && other.action == action && other.parameters == parameters; @@ -34,7 +34,7 @@ class AssetEditActionMirror { (parameters.hashCode); @override - String toString() => 'AssetEditActionMirror[action=$action, parameters=$parameters]'; + String toString() => 'AssetEditActionItemDto[action=$action, parameters=$parameters]'; Map toJson() { final json = {}; @@ -43,27 +43,27 @@ class AssetEditActionMirror { return json; } - /// Returns a new [AssetEditActionMirror] instance and imports its values from + /// Returns a new [AssetEditActionItemDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AssetEditActionMirror? fromJson(dynamic value) { - upgradeDto(value, "AssetEditActionMirror"); + static AssetEditActionItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionItemDto"); if (value is Map) { final json = value.cast(); - return AssetEditActionMirror( + return AssetEditActionItemDto( action: AssetEditAction.fromJson(json[r'action'])!, - parameters: MirrorParameters.fromJson(json[r'parameters'])!, + parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AssetEditActionMirror.fromJson(row); + final value = AssetEditActionItemDto.fromJson(row); if (value != null) { result.add(value); } @@ -72,12 +72,12 @@ class AssetEditActionMirror { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AssetEditActionMirror.fromJson(entry.value); + final value = AssetEditActionItemDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -86,14 +86,14 @@ class AssetEditActionMirror { return map; } - // maps a json object with a list of AssetEditActionMirror-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of AssetEditActionItemDto-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] = AssetEditActionMirror.listFromJson(entry.value, growable: growable,); + map[entry.key] = AssetEditActionItemDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/asset_edit_action_item_dto_parameters.dart b/mobile/openapi/lib/model/asset_edit_action_item_dto_parameters.dart new file mode 100644 index 0000000000..fc67aa022f --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_item_dto_parameters.dart @@ -0,0 +1,153 @@ +// +// 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 AssetEditActionItemDtoParameters { + /// Returns a new [AssetEditActionItemDtoParameters] instance. + AssetEditActionItemDtoParameters({ + required this.height, + required this.width, + required this.x, + required this.y, + required this.angle, + required this.axis, + }); + + /// Height of the crop + /// + /// Minimum value: 1 + num height; + + /// Width of the crop + /// + /// Minimum value: 1 + num width; + + /// Top-Left X coordinate of crop + /// + /// Minimum value: 0 + num x; + + /// Top-Left Y coordinate of crop + /// + /// Minimum value: 0 + num y; + + /// Rotation angle in degrees + num angle; + + /// Axis to mirror along + MirrorAxis axis; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDtoParameters && + other.height == height && + other.width == width && + other.x == x && + other.y == y && + other.angle == angle && + other.axis == axis; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (height.hashCode) + + (width.hashCode) + + (x.hashCode) + + (y.hashCode) + + (angle.hashCode) + + (axis.hashCode); + + @override + String toString() => 'AssetEditActionItemDtoParameters[height=$height, width=$width, x=$x, y=$y, angle=$angle, axis=$axis]'; + + Map toJson() { + final json = {}; + json[r'height'] = this.height; + json[r'width'] = this.width; + json[r'x'] = this.x; + json[r'y'] = this.y; + json[r'angle'] = this.angle; + json[r'axis'] = this.axis; + return json; + } + + /// Returns a new [AssetEditActionItemDtoParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionItemDtoParameters? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionItemDtoParameters"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionItemDtoParameters( + height: num.parse('${json[r'height']}'), + width: num.parse('${json[r'width']}'), + x: num.parse('${json[r'x']}'), + y: num.parse('${json[r'y']}'), + angle: num.parse('${json[r'angle']}'), + axis: MirrorAxis.fromJson(json[r'axis'])!, + ); + } + 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 = AssetEditActionItemDtoParameters.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 = AssetEditActionItemDtoParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionItemDtoParameters-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] = AssetEditActionItemDtoParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'height', + 'width', + 'x', + 'y', + 'angle', + 'axis', + }; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart b/mobile/openapi/lib/model/asset_edit_action_item_response_dto.dart similarity index 54% rename from mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart rename to mobile/openapi/lib/model/asset_edit_action_item_response_dto.dart index 00c9be2381..a23a1ef5f3 100644 --- a/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart +++ b/mobile/openapi/lib/model/asset_edit_action_item_response_dto.dart @@ -10,60 +10,67 @@ part of openapi.api; -class AssetEditActionListDtoEditsInner { - /// Returns a new [AssetEditActionListDtoEditsInner] instance. - AssetEditActionListDtoEditsInner({ +class AssetEditActionItemResponseDto { + /// Returns a new [AssetEditActionItemResponseDto] instance. + AssetEditActionItemResponseDto({ required this.action, + required this.id, required this.parameters, }); /// Type of edit action to perform AssetEditAction action; - MirrorParameters parameters; + String id; + + AssetEditActionItemDtoParameters parameters; @override - bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner && + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemResponseDto && other.action == action && + other.id == id && other.parameters == parameters; @override int get hashCode => // ignore: unnecessary_parenthesis (action.hashCode) + + (id.hashCode) + (parameters.hashCode); @override - String toString() => 'AssetEditActionListDtoEditsInner[action=$action, parameters=$parameters]'; + String toString() => 'AssetEditActionItemResponseDto[action=$action, id=$id, parameters=$parameters]'; Map toJson() { final json = {}; json[r'action'] = this.action; + json[r'id'] = this.id; json[r'parameters'] = this.parameters; return json; } - /// Returns a new [AssetEditActionListDtoEditsInner] instance and imports its values from + /// Returns a new [AssetEditActionItemResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AssetEditActionListDtoEditsInner? fromJson(dynamic value) { - upgradeDto(value, "AssetEditActionListDtoEditsInner"); + static AssetEditActionItemResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionItemResponseDto"); if (value is Map) { final json = value.cast(); - return AssetEditActionListDtoEditsInner( + return AssetEditActionItemResponseDto( action: AssetEditAction.fromJson(json[r'action'])!, - parameters: MirrorParameters.fromJson(json[r'parameters'])!, + id: mapValueOfType(json, r'id')!, + parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AssetEditActionListDtoEditsInner.fromJson(row); + final value = AssetEditActionItemResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -72,12 +79,12 @@ class AssetEditActionListDtoEditsInner { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AssetEditActionListDtoEditsInner.fromJson(entry.value); + final value = AssetEditActionItemResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -86,14 +93,14 @@ class AssetEditActionListDtoEditsInner { return map; } - // maps a json object with a list of AssetEditActionListDtoEditsInner-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of AssetEditActionItemResponseDto-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] = AssetEditActionListDtoEditsInner.listFromJson(entry.value, growable: growable,); + map[entry.key] = AssetEditActionItemResponseDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -102,6 +109,7 @@ class AssetEditActionListDtoEditsInner { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'action', + 'id', 'parameters', }; } diff --git a/mobile/openapi/lib/model/asset_edit_action_list_dto.dart b/mobile/openapi/lib/model/asset_edits_create_dto.dart similarity index 57% rename from mobile/openapi/lib/model/asset_edit_action_list_dto.dart rename to mobile/openapi/lib/model/asset_edits_create_dto.dart index e843c66e8f..9f6fc66904 100644 --- a/mobile/openapi/lib/model/asset_edit_action_list_dto.dart +++ b/mobile/openapi/lib/model/asset_edits_create_dto.dart @@ -10,17 +10,17 @@ part of openapi.api; -class AssetEditActionListDto { - /// Returns a new [AssetEditActionListDto] instance. - AssetEditActionListDto({ +class AssetEditsCreateDto { + /// Returns a new [AssetEditsCreateDto] instance. + AssetEditsCreateDto({ this.edits = const [], }); /// List of edit actions to apply (crop, rotate, or mirror) - List edits; + List edits; @override - bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDto && + bool operator ==(Object other) => identical(this, other) || other is AssetEditsCreateDto && _deepEquality.equals(other.edits, edits); @override @@ -29,7 +29,7 @@ class AssetEditActionListDto { (edits.hashCode); @override - String toString() => 'AssetEditActionListDto[edits=$edits]'; + String toString() => 'AssetEditsCreateDto[edits=$edits]'; Map toJson() { final json = {}; @@ -37,26 +37,26 @@ class AssetEditActionListDto { return json; } - /// Returns a new [AssetEditActionListDto] instance and imports its values from + /// Returns a new [AssetEditsCreateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AssetEditActionListDto? fromJson(dynamic value) { - upgradeDto(value, "AssetEditActionListDto"); + static AssetEditsCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditsCreateDto"); if (value is Map) { final json = value.cast(); - return AssetEditActionListDto( - edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), + return AssetEditsCreateDto( + edits: AssetEditActionItemDto.listFromJson(json[r'edits']), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AssetEditActionListDto.fromJson(row); + final value = AssetEditsCreateDto.fromJson(row); if (value != null) { result.add(value); } @@ -65,12 +65,12 @@ class AssetEditActionListDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AssetEditActionListDto.fromJson(entry.value); + final value = AssetEditsCreateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -79,14 +79,14 @@ class AssetEditActionListDto { return map; } - // maps a json object with a list of AssetEditActionListDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of AssetEditsCreateDto-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] = AssetEditActionListDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = AssetEditsCreateDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/asset_edits_dto.dart b/mobile/openapi/lib/model/asset_edits_response_dto.dart similarity index 57% rename from mobile/openapi/lib/model/asset_edits_dto.dart rename to mobile/openapi/lib/model/asset_edits_response_dto.dart index 3bfbce8594..322b4c0a4c 100644 --- a/mobile/openapi/lib/model/asset_edits_dto.dart +++ b/mobile/openapi/lib/model/asset_edits_response_dto.dart @@ -10,21 +10,21 @@ part of openapi.api; -class AssetEditsDto { - /// Returns a new [AssetEditsDto] instance. - AssetEditsDto({ +class AssetEditsResponseDto { + /// Returns a new [AssetEditsResponseDto] instance. + AssetEditsResponseDto({ required this.assetId, this.edits = const [], }); - /// Asset ID to apply edits to + /// Asset ID these edits belong to String assetId; - /// List of edit actions to apply (crop, rotate, or mirror) - List edits; + /// List of edit actions applied to the asset + List edits; @override - bool operator ==(Object other) => identical(this, other) || other is AssetEditsDto && + bool operator ==(Object other) => identical(this, other) || other is AssetEditsResponseDto && other.assetId == assetId && _deepEquality.equals(other.edits, edits); @@ -35,7 +35,7 @@ class AssetEditsDto { (edits.hashCode); @override - String toString() => 'AssetEditsDto[assetId=$assetId, edits=$edits]'; + String toString() => 'AssetEditsResponseDto[assetId=$assetId, edits=$edits]'; Map toJson() { final json = {}; @@ -44,27 +44,27 @@ class AssetEditsDto { return json; } - /// Returns a new [AssetEditsDto] instance and imports its values from + /// Returns a new [AssetEditsResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AssetEditsDto? fromJson(dynamic value) { - upgradeDto(value, "AssetEditsDto"); + static AssetEditsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditsResponseDto"); if (value is Map) { final json = value.cast(); - return AssetEditsDto( + return AssetEditsResponseDto( assetId: mapValueOfType(json, r'assetId')!, - edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), + edits: AssetEditActionItemResponseDto.listFromJson(json[r'edits']), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AssetEditsDto.fromJson(row); + final value = AssetEditsResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +73,12 @@ class AssetEditsDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AssetEditsDto.fromJson(entry.value); + final value = AssetEditsResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +87,14 @@ class AssetEditsDto { return map; } - // maps a json object with a list of AssetEditsDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of AssetEditsResponseDto-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] = AssetEditsDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = AssetEditsResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 5422ccf55f..078dd0bdaf 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -156,7 +156,7 @@ class AssetResponseDto { List tags; - /// Thumbhash for thumbnail generation + /// Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting. String? thumbhash; /// Asset type 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/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart index 7fd938b31a..5b8eeed8fb 100644 --- a/mobile/openapi/lib/model/memory_create_dto.dart +++ b/mobile/openapi/lib/model/memory_create_dto.dart @@ -15,9 +15,11 @@ class MemoryCreateDto { MemoryCreateDto({ this.assetIds = const [], required this.data, + this.hideAt, this.isSaved, required this.memoryAt, this.seenAt, + this.showAt, required this.type, }); @@ -26,6 +28,15 @@ class MemoryCreateDto { OnThisDayDto data; + /// Date when memory should be hidden + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? hideAt; + /// Is memory saved /// /// Please note: This property should have been non-nullable! Since the specification file @@ -47,6 +58,15 @@ class MemoryCreateDto { /// DateTime? seenAt; + /// Date when memory should be shown + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? showAt; + /// Memory type MemoryType type; @@ -54,9 +74,11 @@ class MemoryCreateDto { bool operator ==(Object other) => identical(this, other) || other is MemoryCreateDto && _deepEquality.equals(other.assetIds, assetIds) && other.data == data && + other.hideAt == hideAt && other.isSaved == isSaved && other.memoryAt == memoryAt && other.seenAt == seenAt && + other.showAt == showAt && other.type == type; @override @@ -64,18 +86,25 @@ class MemoryCreateDto { // ignore: unnecessary_parenthesis (assetIds.hashCode) + (data.hashCode) + + (hideAt == null ? 0 : hideAt!.hashCode) + (isSaved == null ? 0 : isSaved!.hashCode) + (memoryAt.hashCode) + (seenAt == null ? 0 : seenAt!.hashCode) + + (showAt == null ? 0 : showAt!.hashCode) + (type.hashCode); @override - String toString() => 'MemoryCreateDto[assetIds=$assetIds, data=$data, isSaved=$isSaved, memoryAt=$memoryAt, seenAt=$seenAt, type=$type]'; + String toString() => 'MemoryCreateDto[assetIds=$assetIds, data=$data, hideAt=$hideAt, isSaved=$isSaved, memoryAt=$memoryAt, seenAt=$seenAt, showAt=$showAt, type=$type]'; Map toJson() { final json = {}; json[r'assetIds'] = this.assetIds; json[r'data'] = this.data; + if (this.hideAt != null) { + json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String(); + } else { + // json[r'hideAt'] = null; + } if (this.isSaved != null) { json[r'isSaved'] = this.isSaved; } else { @@ -86,6 +115,11 @@ class MemoryCreateDto { json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); } else { // json[r'seenAt'] = null; + } + if (this.showAt != null) { + json[r'showAt'] = this.showAt!.toUtc().toIso8601String(); + } else { + // json[r'showAt'] = null; } json[r'type'] = this.type; return json; @@ -104,9 +138,11 @@ class MemoryCreateDto { ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], data: OnThisDayDto.fromJson(json[r'data'])!, + hideAt: mapDateTime(json, r'hideAt', r''), isSaved: mapValueOfType(json, r'isSaved'), memoryAt: mapDateTime(json, r'memoryAt', r'')!, seenAt: mapDateTime(json, r'seenAt', r''), + showAt: mapDateTime(json, r'showAt', r''), type: MemoryType.fromJson(json[r'type'])!, ); } diff --git a/mobile/openapi/lib/model/asset_edit_action_rotate.dart b/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart similarity index 52% rename from mobile/openapi/lib/model/asset_edit_action_rotate.dart rename to mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart index 302e6a0ce6..68af280290 100644 --- a/mobile/openapi/lib/model/asset_edit_action_rotate.dart +++ b/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart @@ -10,60 +10,52 @@ part of openapi.api; -class AssetEditActionRotate { - /// Returns a new [AssetEditActionRotate] instance. - AssetEditActionRotate({ - required this.action, - required this.parameters, +class SyncAssetEditDeleteV1 { + /// Returns a new [SyncAssetEditDeleteV1] instance. + SyncAssetEditDeleteV1({ + required this.editId, }); - /// Type of edit action to perform - AssetEditAction action; - - RotateParameters parameters; + String editId; @override - bool operator ==(Object other) => identical(this, other) || other is AssetEditActionRotate && - other.action == action && - other.parameters == parameters; + bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditDeleteV1 && + other.editId == editId; @override int get hashCode => // ignore: unnecessary_parenthesis - (action.hashCode) + - (parameters.hashCode); + (editId.hashCode); @override - String toString() => 'AssetEditActionRotate[action=$action, parameters=$parameters]'; + String toString() => 'SyncAssetEditDeleteV1[editId=$editId]'; Map toJson() { final json = {}; - json[r'action'] = this.action; - json[r'parameters'] = this.parameters; + json[r'editId'] = this.editId; return json; } - /// Returns a new [AssetEditActionRotate] instance and imports its values from + /// Returns a new [SyncAssetEditDeleteV1] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AssetEditActionRotate? fromJson(dynamic value) { - upgradeDto(value, "AssetEditActionRotate"); + static SyncAssetEditDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetEditDeleteV1"); if (value is Map) { final json = value.cast(); - return AssetEditActionRotate( - action: AssetEditAction.fromJson(json[r'action'])!, - parameters: RotateParameters.fromJson(json[r'parameters'])!, + return SyncAssetEditDeleteV1( + editId: mapValueOfType(json, r'editId')!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AssetEditActionRotate.fromJson(row); + final value = SyncAssetEditDeleteV1.fromJson(row); if (value != null) { result.add(value); } @@ -72,12 +64,12 @@ class AssetEditActionRotate { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AssetEditActionRotate.fromJson(entry.value); + final value = SyncAssetEditDeleteV1.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -86,14 +78,14 @@ class AssetEditActionRotate { return map; } - // maps a json object with a list of AssetEditActionRotate-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of SyncAssetEditDeleteV1-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] = AssetEditActionRotate.listFromJson(entry.value, growable: growable,); + map[entry.key] = SyncAssetEditDeleteV1.listFromJson(entry.value, growable: growable,); } } return map; @@ -101,8 +93,7 @@ class AssetEditActionRotate { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'action', - 'parameters', + 'editId', }; } diff --git a/mobile/openapi/lib/model/sync_asset_edit_v1.dart b/mobile/openapi/lib/model/sync_asset_edit_v1.dart new file mode 100644 index 0000000000..3cc2673bfc --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_edit_v1.dart @@ -0,0 +1,131 @@ +// +// 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 SyncAssetEditV1 { + /// Returns a new [SyncAssetEditV1] instance. + SyncAssetEditV1({ + required this.action, + required this.assetId, + required this.id, + required this.parameters, + required this.sequence, + }); + + AssetEditAction action; + + String assetId; + + String id; + + Object parameters; + + int sequence; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditV1 && + other.action == action && + other.assetId == assetId && + other.id == id && + other.parameters == parameters && + other.sequence == sequence; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (assetId.hashCode) + + (id.hashCode) + + (parameters.hashCode) + + (sequence.hashCode); + + @override + String toString() => 'SyncAssetEditV1[action=$action, assetId=$assetId, id=$id, parameters=$parameters, sequence=$sequence]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'assetId'] = this.assetId; + json[r'id'] = this.id; + json[r'parameters'] = this.parameters; + json[r'sequence'] = this.sequence; + return json; + } + + /// Returns a new [SyncAssetEditV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetEditV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetEditV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetEditV1( + action: AssetEditAction.fromJson(json[r'action'])!, + assetId: mapValueOfType(json, r'assetId')!, + id: mapValueOfType(json, r'id')!, + parameters: mapValueOfType(json, r'parameters')!, + sequence: mapValueOfType(json, r'sequence')!, + ); + } + 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 = SyncAssetEditV1.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 = SyncAssetEditV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetEditV1-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] = SyncAssetEditV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'assetId', + 'id', + 'parameters', + 'sequence', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_face_v2.dart b/mobile/openapi/lib/model/sync_asset_face_v2.dart new file mode 100644 index 0000000000..688d71229f --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_face_v2.dart @@ -0,0 +1,201 @@ +// +// 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 SyncAssetFaceV2 { + /// Returns a new [SyncAssetFaceV2] instance. + SyncAssetFaceV2({ + required this.assetId, + required this.boundingBoxX1, + required this.boundingBoxX2, + required this.boundingBoxY1, + required this.boundingBoxY2, + required this.deletedAt, + required this.id, + required this.imageHeight, + required this.imageWidth, + required this.isVisible, + required this.personId, + required this.sourceType, + }); + + /// Asset ID + String assetId; + + int boundingBoxX1; + + int boundingBoxX2; + + int boundingBoxY1; + + int boundingBoxY2; + + /// Face deleted at + DateTime? deletedAt; + + /// Asset face ID + String id; + + int imageHeight; + + int imageWidth; + + /// Is the face visible in the asset + bool isVisible; + + /// Person ID + String? personId; + + /// Source type + String sourceType; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetFaceV2 && + other.assetId == assetId && + other.boundingBoxX1 == boundingBoxX1 && + other.boundingBoxX2 == boundingBoxX2 && + other.boundingBoxY1 == boundingBoxY1 && + other.boundingBoxY2 == boundingBoxY2 && + other.deletedAt == deletedAt && + other.id == id && + other.imageHeight == imageHeight && + other.imageWidth == imageWidth && + other.isVisible == isVisible && + other.personId == personId && + other.sourceType == sourceType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (boundingBoxX1.hashCode) + + (boundingBoxX2.hashCode) + + (boundingBoxY1.hashCode) + + (boundingBoxY2.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (id.hashCode) + + (imageHeight.hashCode) + + (imageWidth.hashCode) + + (isVisible.hashCode) + + (personId == null ? 0 : personId!.hashCode) + + (sourceType.hashCode); + + @override + String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, personId=$personId, sourceType=$sourceType]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'boundingBoxX1'] = this.boundingBoxX1; + json[r'boundingBoxX2'] = this.boundingBoxX2; + json[r'boundingBoxY1'] = this.boundingBoxY1; + json[r'boundingBoxY2'] = this.boundingBoxY2; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'id'] = this.id; + json[r'imageHeight'] = this.imageHeight; + json[r'imageWidth'] = this.imageWidth; + json[r'isVisible'] = this.isVisible; + if (this.personId != null) { + json[r'personId'] = this.personId; + } else { + // json[r'personId'] = null; + } + json[r'sourceType'] = this.sourceType; + return json; + } + + /// Returns a new [SyncAssetFaceV2] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetFaceV2? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetFaceV2"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetFaceV2( + assetId: mapValueOfType(json, r'assetId')!, + boundingBoxX1: mapValueOfType(json, r'boundingBoxX1')!, + boundingBoxX2: mapValueOfType(json, r'boundingBoxX2')!, + boundingBoxY1: mapValueOfType(json, r'boundingBoxY1')!, + boundingBoxY2: mapValueOfType(json, r'boundingBoxY2')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + id: mapValueOfType(json, r'id')!, + imageHeight: mapValueOfType(json, r'imageHeight')!, + imageWidth: mapValueOfType(json, r'imageWidth')!, + isVisible: mapValueOfType(json, r'isVisible')!, + personId: mapValueOfType(json, r'personId'), + sourceType: mapValueOfType(json, r'sourceType')!, + ); + } + 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 = SyncAssetFaceV2.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 = SyncAssetFaceV2.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetFaceV2-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] = SyncAssetFaceV2.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'boundingBoxX1', + 'boundingBoxX2', + 'boundingBoxY1', + 'boundingBoxY2', + 'deletedAt', + 'id', + 'imageHeight', + 'imageWidth', + 'isVisible', + 'personId', + 'sourceType', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index d1e321f39b..e8db2dc4d3 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -29,6 +29,8 @@ class SyncEntityType { static const assetV1 = SyncEntityType._(r'AssetV1'); static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); + static const assetEditV1 = SyncEntityType._(r'AssetEditV1'); + static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1'); static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1'); static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1'); static const partnerV1 = SyncEntityType._(r'PartnerV1'); @@ -64,6 +66,7 @@ class SyncEntityType { static const personV1 = SyncEntityType._(r'PersonV1'); static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1'); static const assetFaceV1 = SyncEntityType._(r'AssetFaceV1'); + static const assetFaceV2 = SyncEntityType._(r'AssetFaceV2'); static const assetFaceDeleteV1 = SyncEntityType._(r'AssetFaceDeleteV1'); static const userMetadataV1 = SyncEntityType._(r'UserMetadataV1'); static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1'); @@ -79,6 +82,8 @@ class SyncEntityType { assetV1, assetDeleteV1, assetExifV1, + assetEditV1, + assetEditDeleteV1, assetMetadataV1, assetMetadataDeleteV1, partnerV1, @@ -114,6 +119,7 @@ class SyncEntityType { personV1, personDeleteV1, assetFaceV1, + assetFaceV2, assetFaceDeleteV1, userMetadataV1, userMetadataDeleteV1, @@ -164,6 +170,8 @@ class SyncEntityTypeTypeTransformer { case r'AssetV1': return SyncEntityType.assetV1; case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; case r'AssetExifV1': return SyncEntityType.assetExifV1; + case r'AssetEditV1': return SyncEntityType.assetEditV1; + case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1; case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1; case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1; case r'PartnerV1': return SyncEntityType.partnerV1; @@ -199,6 +207,7 @@ class SyncEntityTypeTypeTransformer { case r'PersonV1': return SyncEntityType.personV1; case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; case r'AssetFaceV1': return SyncEntityType.assetFaceV1; + case r'AssetFaceV2': return SyncEntityType.assetFaceV2; case r'AssetFaceDeleteV1': return SyncEntityType.assetFaceDeleteV1; case r'UserMetadataV1': return SyncEntityType.userMetadataV1; case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 135af3c7bb..671081c0a5 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -30,6 +30,7 @@ class SyncRequestType { static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); static const assetsV1 = SyncRequestType._(r'AssetsV1'); static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); + static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1'); static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1'); static const authUsersV1 = SyncRequestType._(r'AuthUsersV1'); static const memoriesV1 = SyncRequestType._(r'MemoriesV1'); @@ -42,6 +43,7 @@ class SyncRequestType { static const usersV1 = SyncRequestType._(r'UsersV1'); static const peopleV1 = SyncRequestType._(r'PeopleV1'); static const assetFacesV1 = SyncRequestType._(r'AssetFacesV1'); + static const assetFacesV2 = SyncRequestType._(r'AssetFacesV2'); static const userMetadataV1 = SyncRequestType._(r'UserMetadataV1'); /// List of all possible values in this [enum][SyncRequestType]. @@ -53,6 +55,7 @@ class SyncRequestType { albumAssetExifsV1, assetsV1, assetExifsV1, + assetEditsV1, assetMetadataV1, authUsersV1, memoriesV1, @@ -65,6 +68,7 @@ class SyncRequestType { usersV1, peopleV1, assetFacesV1, + assetFacesV2, userMetadataV1, ]; @@ -111,6 +115,7 @@ class SyncRequestTypeTypeTransformer { case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; case r'AssetsV1': return SyncRequestType.assetsV1; case r'AssetExifsV1': return SyncRequestType.assetExifsV1; + case r'AssetEditsV1': return SyncRequestType.assetEditsV1; case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1; case r'AuthUsersV1': return SyncRequestType.authUsersV1; case r'MemoriesV1': return SyncRequestType.memoriesV1; @@ -123,6 +128,7 @@ class SyncRequestTypeTypeTransformer { case r'UsersV1': return SyncRequestType.usersV1; case r'PeopleV1': return SyncRequestType.peopleV1; case r'AssetFacesV1': return SyncRequestType.assetFacesV1; + case r'AssetFacesV2': return SyncRequestType.assetFacesV2; case r'UserMetadataV1': return SyncRequestType.userMetadataV1; default: if (!allowNull) { 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..c9e510a162 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/formatted_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/formatted_text.dart b/mobile/packages/ui/lib/src/components/formatted_text.dart new file mode 100644 index 0000000000..95e42d834d --- /dev/null +++ b/mobile/packages/ui/lib/src/components/formatted_text.dart @@ -0,0 +1,141 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class FormattedSpan { + final TextStyle? style; + final VoidCallback? onTap; + + const FormattedSpan({this.style, this.onTap}); +} + +/// A widget that renders text with optional HTML-style formatting. +/// +/// Supports the following tags: +/// - `` for bold text +/// - `` or any tag ending with `-link` for tappable links +/// +/// Tags must not be nested. Each tag is matched independently left-to-right. +/// +/// By default, `` renders as [FontWeight.bold] and link tags render with an +/// underline and no tap handler. Provide [spanBuilder] to attach tap callbacks +/// or override styles per tag. +/// +/// Bold-only example (no [spanBuilder] needed): +/// ```dart +/// ImmichFormattedText('Delete {count} items?') +/// ``` +/// +/// Link example: +/// ```dart +/// ImmichFormattedText( +/// 'Refer to docs and other', +/// spanBuilder: (tag) => FormattedSpan( +/// onTap: switch (tag) { +/// 'docs-link' => () => launchUrl(docsUrl), +/// 'other-link' => () => launchUrl(otherUrl), +/// _ => null, +/// }, +/// ), +/// ) +/// ``` +class ImmichFormattedText extends StatefulWidget { + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final TextOverflow? overflow; + final int? maxLines; + final bool? softWrap; + final FormattedSpan Function(String tag)? spanBuilder; + + const ImmichFormattedText( + this.text, { + this.spanBuilder, + super.key, + this.style, + this.textAlign, + this.overflow, + this.maxLines, + this.softWrap, + }); + + @override + State createState() => _ImmichFormattedTextState(); +} + +class _ImmichFormattedTextState extends State { + final _recognizers = []; + + // Matches , , or any *-link tag and its content. + static final _tagPattern = RegExp(r'<(b|link|[\w]+-link)>(.*?)', caseSensitive: false, dotAll: true); + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + List _buildSpans() { + _disposeRecognizers(); + + final spans = []; + int cursor = 0; + + for (final match in _tagPattern.allMatches(widget.text)) { + if (match.start > cursor) { + spans.add(TextSpan(text: widget.text.substring(cursor, match.start))); + } + + final tag = match.group(1)!.toLowerCase(); + final content = match.group(2)!; + final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag); + final style = formattedSpan.style ?? _defaultTextStyle(tag); + + GestureRecognizer? recognizer; + if (formattedSpan.onTap != null) { + recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap; + _recognizers.add(recognizer); + } + spans.add(TextSpan(text: content, style: style, recognizer: recognizer)); + + cursor = match.end; + } + + if (cursor < widget.text.length) { + spans.add(TextSpan(text: widget.text.substring(cursor))); + } + + return spans; + } + + FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) { + 'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)), + 'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)), + _ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)), + _ => const FormattedSpan(), + }; + + TextStyle? _defaultTextStyle(String tag) => switch (tag) { + 'b' => const TextStyle(fontWeight: FontWeight.bold), + 'link' => const TextStyle(decoration: TextDecoration.underline), + _ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline), + _ => 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..697e1debf5 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,56 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + 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" + 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 +103,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 +176,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..a25dfb6ca4 100644 --- a/mobile/packages/ui/pubspec.yaml +++ b/mobile/packages/ui/pubspec.yaml @@ -8,5 +8,9 @@ dependencies: flutter: sdk: flutter +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/formatted_text_bold_text.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_bold_text.dart new file mode 100644 index 0000000000..7e36ac7537 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_bold_text.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class FormattedTextBoldText extends StatelessWidget { + const FormattedTextBoldText({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichFormattedText('This is bold text.'); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_links.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_links.dart new file mode 100644 index 0000000000..3910a5117a --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_links.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class FormattedTextLinks extends StatelessWidget { + const FormattedTextLinks({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichFormattedText( + 'Read the documentation or visit GitHub.', + spanBuilder: (tag) => FormattedSpan( + onTap: switch (tag) { + '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!'))), + _ => null, + }, + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_mixed_tags.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_mixed_tags.dart new file mode 100644 index 0000000000..3490b1c386 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_mixed_tags.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class FormattedTextMixedContent extends StatelessWidget { + const FormattedTextMixedContent({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichFormattedText( + 'You can use bold text and links together.', + spanBuilder: (tag) => switch (tag) { + 'b' => const FormattedSpan( + style: TextStyle(fontWeight: FontWeight.bold), + ), + _ => FormattedSpan( + onTap: () => ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('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/formatted_text_page.dart b/mobile/packages/ui/showcase/lib/pages/components/formatted_text_page.dart new file mode 100644 index 0000000000..b827e0340b --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/formatted_text_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:showcase/pages/components/examples/formatted_text_bold_text.dart'; +import 'package:showcase/pages/components/examples/formatted_text_links.dart'; +import 'package:showcase/pages/components/examples/formatted_text_mixed_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 FormattedTextPage extends StatelessWidget { + const FormattedTextPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.formattedText.name, + child: ComponentExamples( + title: 'ImmichFormattedText', + subtitle: 'Render text with HTML formatting (bold, links).', + examples: [ + ExampleCard( + title: 'Bold Text', + preview: const FormattedTextBoldText(), + code: 'formatted_text_bold_text.dart', + ), + ExampleCard( + title: 'Links', + preview: const FormattedTextLinks(), + code: 'formatted_text_links.dart', + ), + ExampleCard( + title: 'Mixed Content', + preview: const FormattedTextMixedContent(), + code: 'formatted_text_mixed_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..34393da508 --- /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/formatted_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.formattedText => const FormattedTextPage(), + 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..4feeeafdb6 --- /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, + ), + formattedText( + name: 'Formatted Text', + description: 'Render text with HTML formatting', + path: '/formatted-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..c79e6c18c7 --- /dev/null +++ b/mobile/packages/ui/showcase/pubspec.lock @@ -0,0 +1,377 @@ +# 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" + 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" + 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: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.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: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + 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/formatted_text_test.dart b/mobile/packages/ui/test/formatted_text_test.dart new file mode 100644 index 0000000000..54ef343727 --- /dev/null +++ b/mobile/packages/ui/test/formatted_text_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/components/formatted_text.dart'; + +import 'test_utils.dart'; + +/// Text.rich creates a nested structure: root (DefaultTextStyle) -> wrapper (ImmichFormattedText) -> actual children +List _getContentSpans(WidgetTester tester) { + final richText = tester.widget(find.byType(RichText)); + final root = richText.text as TextSpan; + final wrapper = root.children?.firstOrNull; + if (wrapper is TextSpan) 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('ImmichFormattedText', () { + testWidgets('renders plain text without HTML tags', (tester) async { + await tester.pumpTestWidget( + const ImmichFormattedText('This is plain text'), + ); + + expect(find.text('This is plain text'), findsOneWidget); + }); + + testWidgets('applies text style properties', (tester) async { + await tester.pumpTestWidget( + const ImmichFormattedText( + '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 ImmichFormattedText('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 ImmichFormattedText('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'); + }); + }); + + group('link', () { + testWidgets('renders link text with tag', (tester) async { + await tester.pumpTestWidget( + ImmichFormattedText( + 'This is a custom link text', + spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () {}, _ => null }), + ), + ); + + 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( + ImmichFormattedText( + 'Tap here', + spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () => linkTapped = true, _ => null }), + ), + ); + + 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( + ImmichFormattedText( + 'Refer to docs and other', + spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { + 'docs-link' => () {}, + 'other-link' => () {}, + _ => null, + },), + ), + ); + + 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( + ImmichFormattedText( + 'Click here', + spanBuilder: (tag) => FormattedSpan(style: customLinkStyle, onTap: () {}), + ), + ); + + 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( + ImmichFormattedText( + 'Link without handler: click me', + spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'other-link' => () {}, _ => null }), + ), + ); + + 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( + ImmichFormattedText( + 'Go to docs or help', + spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { + 'docs-link' => () => firstLinkTapped = true, + 'help-link' => () => secondLinkTapped = true, + _ => null, + },), + ), + ); + + 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/pubspec.lock b/mobile/pubspec.lock index 28adfc2ab7..077544b4f7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1217,10 +1217,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1910,10 +1910,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: diff --git a/mobile/test/domain/services/album.service_test.dart b/mobile/test/domain/services/album.service_test.dart index 1a36a811c3..9110a09471 100644 --- a/mobile/test/domain/services/album.service_test.dart +++ b/mobile/test/domain/services/album.service_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; @@ -13,38 +14,6 @@ void main() { late DriftRemoteAlbumRepository mockRemoteAlbumRepo; late DriftAlbumApiRepository mockAlbumApiRepo; - setUp(() { - mockRemoteAlbumRepo = MockRemoteAlbumRepository(); - mockAlbumApiRepo = MockDriftAlbumApiRepository(); - sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo); - - when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) { - // Simulate a timestamp for the newest asset in the album - final albumID = invocation.positionalArguments[0] as String; - - if (albumID == '1') { - return Future.value(DateTime(2023, 1, 1)); - } else if (albumID == '2') { - return Future.value(DateTime(2023, 2, 1)); - } - - return Future.value(DateTime.fromMillisecondsSinceEpoch(0)); - }); - - when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) { - // Simulate a timestamp for the oldest asset in the album - final albumID = invocation.positionalArguments[0] as String; - - if (albumID == '1') { - return Future.value(DateTime(2019, 1, 1)); - } else if (albumID == '2') { - return Future.value(DateTime(2019, 2, 1)); - } - - return Future.value(DateTime.fromMillisecondsSinceEpoch(0)); - }); - }); - final albumA = RemoteAlbum( id: '1', name: 'Album A', @@ -73,6 +42,21 @@ void main() { isShared: false, ); + setUp(() { + mockRemoteAlbumRepo = MockRemoteAlbumRepository(); + mockAlbumApiRepo = MockDriftAlbumApiRepository(); + + when( + () => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.end), + ).thenAnswer((_) async => ['1', '2']); + + when( + () => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.start), + ).thenAnswer((_) async => ['1', '2']); + + sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo); + }); + group('sortAlbums', () { test('should sort correctly based on name', () async { final albums = [albumB, albumA]; diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 0eabf3b612..a182c6cdca 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; +import 'package:immich_mobile/utils/semver.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; @@ -66,6 +67,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); debugDefaultTargetPlatformOverride = TargetPlatform.android; registerFallbackValue(LocalAssetStub.image1); + registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0)); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db)); @@ -94,11 +96,19 @@ void main() { when(() => mockAbortCallbackWrapper()).thenReturn(false); - when(() => mockSyncApiRepo.streamChanges(any())).thenAnswer((invocation) async { + when(() => mockSyncApiRepo.streamChanges(any(), serverVersion: any(named: 'serverVersion'))).thenAnswer(( + invocation, + ) async { handleEventsCallback = invocation.positionalArguments.first; }); - when(() => mockSyncApiRepo.streamChanges(any(), onReset: any(named: 'onReset'))).thenAnswer((invocation) async { + when( + () => mockSyncApiRepo.streamChanges( + any(), + onReset: any(named: 'onReset'), + serverVersion: any(named: 'serverVersion'), + ), + ).thenAnswer((invocation) async { handleEventsCallback = invocation.positionalArguments.first; }); @@ -106,9 +116,9 @@ void main() { when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {}); when(() => mockApi.serverInfoApi).thenReturn(mockServerApi); - when(() => mockServerApi.getServerVersion()).thenAnswer( - (_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0), - ); + when( + () => mockServerApi.getServerVersion(), + ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0)); when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler); diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index d9f18b3007..2ec39fafde 100644 --- a/mobile/test/drift/main/generated/schema.dart +++ b/mobile/test/drift/main/generated/schema.dart @@ -22,6 +22,7 @@ import 'schema_v16.dart' as v16; import 'schema_v17.dart' as v17; import 'schema_v18.dart' as v18; import 'schema_v19.dart' as v19; +import 'schema_v20.dart' as v20; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -65,6 +66,8 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v18.DatabaseAtV18(db); case 19: return v19.DatabaseAtV19(db); + case 20: + return v20.DatabaseAtV20(db); default: throw MissingSchemaException(version, versions); } @@ -90,5 +93,6 @@ class GeneratedHelper implements SchemaInstantiationHelper { 17, 18, 19, + 20, ]; } diff --git a/mobile/test/drift/main/generated/schema_v20.dart b/mobile/test/drift/main/generated/schema_v20.dart new file mode 100644 index 0000000000..8f7b204f7a --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v20.dart @@ -0,0 +1,8471 @@ +// 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, + ); + late final GeneratedColumn isVisible = GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ]; + @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'], + )!, + isVisible: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_visible'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ); + } + + @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; + final bool isVisible; + final DateTime? deletedAt; + 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, + required this.isVisible, + this.deletedAt, + }); + @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); + map['is_visible'] = Variable(isVisible); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + 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']), + isVisible: serializer.fromJson(json['isVisible']), + deletedAt: serializer.fromJson(json['deletedAt']), + ); + } + @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), + 'isVisible': serializer.toJson(isVisible), + 'deletedAt': serializer.toJson(deletedAt), + }; + } + + 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, + bool? isVisible, + Value deletedAt = const Value.absent(), + }) => 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, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ); + 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, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ); + } + + @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('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ); + @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 && + other.isVisible == this.isVisible && + other.deletedAt == this.deletedAt); +} + +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; + final Value isVisible; + final Value deletedAt; + 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(), + this.isVisible = const Value.absent(), + this.deletedAt = 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, + this.isVisible = const Value.absent(), + this.deletedAt = const Value.absent(), + }) : 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, + Expression? isVisible, + Expression? deletedAt, + }) { + 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, + if (isVisible != null) 'is_visible': isVisible, + if (deletedAt != null) 'deleted_at': deletedAt, + }); + } + + AssetFaceEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? personId, + Value? imageWidth, + Value? imageHeight, + Value? boundingBoxX1, + Value? boundingBoxY1, + Value? boundingBoxX2, + Value? boundingBoxY2, + Value? sourceType, + Value? isVisible, + Value? deletedAt, + }) { + 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, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt ?? this.deletedAt, + ); + } + + @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); + } + if (isVisible.present) { + map['is_visible'] = Variable(isVisible.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.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('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..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 DatabaseAtV20 extends GeneratedDatabase { + DatabaseAtV20(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 => 20; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/mobile/test/infrastructure/repositories/backup_repository_test.dart b/mobile/test/infrastructure/repositories/backup_repository_test.dart new file mode 100644 index 0000000000..c042685779 --- /dev/null +++ b/mobile/test/infrastructure/repositories/backup_repository_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; + +import '../../medium/repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late DriftBackupRepository sut; + + setUp(() { + ctx = MediumRepositoryContext(); + sut = DriftBackupRepository(ctx.db); + }); + + tearDown(() async { + await ctx.dispose(); + }); + + group('getAllCounts', () { + late String userId; + + setUp(() async { + final user = await ctx.newUser(); + userId = user.id; + }); + + test('returns zeros when no albums exist', () async { + final result = await sut.getAllCounts(userId); + expect(result.total, 0); + expect(result.remainder, 0); + expect(result.processing, 0); + }); + + test('returns zeros when no selected albums exist', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 0); + expect(result.remainder, 0); + expect(result.processing, 0); + }); + + test('counts asset in selected album as total and remainder', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 1); + expect(result.remainder, 1); + expect(result.processing, 0); + }); + + test('backed up asset reduces remainder', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final remote = await ctx.newRemoteAsset(ownerId: userId); + final local = await ctx.newLocalAsset(checksum: remote.checksum); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 1); + expect(result.remainder, 0); + expect(result.processing, 0); + }); + + test('asset with null checksum is counted as processing', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(checksumOption: const Option.none()); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 1); + expect(result.remainder, 1); + expect(result.processing, 1); + }); + + test('asset in excluded album is not counted even if also in selected album', () async { + final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id); + await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 0); + expect(result.remainder, 0); + }); + + test('counts assets across multiple selected albums without duplicates', () async { + final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + // Same asset in two selected albums + await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id); + await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 1); + }); + + test('backed up asset for different user is still counted as remainder', () async { + final otherUser = await ctx.newUser(); + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final remote = await ctx.newRemoteAsset(ownerId: otherUser.id); + final local = await ctx.newLocalAsset(checksum: remote.checksum); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 1); + expect(result.remainder, 1); + }); + + test('mixed assets produce correct combined counts', () async { + final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + + // backed up + final remote1 = await ctx.newRemoteAsset(ownerId: userId); + final local1 = await ctx.newLocalAsset(checksum: remote1.checksum); + await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local1.id); + + // not backed up, has checksum + final local2 = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local2.id); + + // processing (null checksum) + final local3 = await ctx.newLocalAsset(checksumOption: const Option.none()); + await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local3.id); + + final result = await sut.getAllCounts(userId); + expect(result.total, 3); + expect(result.remainder, 2); // local2 + local3 + expect(result.processing, 1); // local3 + }); + }); + + group('getCandidates', () { + late String userId; + + setUp(() async { + final user = await ctx.newUser(); + userId = user.id; + }); + + test('returns empty list when no selected albums exist', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getCandidates(userId); + expect(result, isEmpty); + }); + + test('returns asset in selected album that is not backed up', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getCandidates(userId); + expect(result.length, 1); + expect(result.first.id, asset.id); + }); + + test('excludes asset already backed up for the same user', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final remote = await ctx.newRemoteAsset(ownerId: userId); + final local = await ctx.newLocalAsset(checksum: remote.checksum); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id); + + final result = await sut.getCandidates(userId); + expect(result, isEmpty); + }); + + test('includes asset backed up for a different user', () async { + final otherUser = await ctx.newUser(); + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final remote = await ctx.newRemoteAsset(ownerId: otherUser.id); + final local = await ctx.newLocalAsset(checksum: remote.checksum); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id); + + final result = await sut.getCandidates(userId); + expect(result.length, 1); + expect(result.first.id, local.id); + }); + + test('excludes asset in excluded album even if also in selected album', () async { + final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id); + await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id); + + final result = await sut.getCandidates(userId); + expect(result, isEmpty); + }); + + test('excludes asset with null checksum when onlyHashed is true', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(checksumOption: const Option.none()); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getCandidates(userId); + expect(result, isEmpty); + }); + + test('includes asset with null checksum when onlyHashed is false', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(checksumOption: const Option.none()); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getCandidates(userId, onlyHashed: false); + expect(result.length, 1); + expect(result.first.id, asset.id); + }); + + test('returns assets ordered by createdAt descending', () async { + final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset1 = await ctx.newLocalAsset(createdAt: DateTime(2024, 1, 1)); + final asset2 = await ctx.newLocalAsset(createdAt: DateTime(2024, 3, 1)); + final asset3 = await ctx.newLocalAsset(createdAt: DateTime(2024, 2, 1)); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset1.id); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset2.id); + await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset3.id); + + final result = await sut.getCandidates(userId); + expect(result.map((a) => a.id).toList(), [asset2.id, asset3.id, asset1.id]); + }); + + test('does not return duplicate when asset is in multiple selected albums', () async { + final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected); + final asset = await ctx.newLocalAsset(); + await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id); + await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id); + + final result = await sut.getCandidates(userId); + expect(result.length, 1); + expect(result.first.id, asset.id); + }); + }); +} diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart index 245cc86a98..88f8d00e03 100644 --- a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -1,975 +1,569 @@ -import 'package:drift/drift.dart' hide isNull; -import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; + +import '../../medium/repository_context.dart'; void main() { - final now = DateTime(2024, 1, 15); - late Drift db; - late DriftLocalAssetRepository repository; + late MediumRepositoryContext ctx; + late DriftLocalAssetRepository sut; setUp(() { - db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - repository = DriftLocalAssetRepository(db); + ctx = MediumRepositoryContext(); + sut = DriftLocalAssetRepository(ctx.db); }); tearDown(() async { - await db.close(); + await ctx.dispose(); }); - Future insertLocalAsset({ - required String id, - String? checksum, - DateTime? createdAt, - AssetType type = AssetType.image, - bool isFavorite = false, - String? iCloudId, - DateTime? adjustmentTime, - double? latitude, - double? longitude, - }) async { - final created = createdAt ?? now; - await db - .into(db.localAssetEntity) - .insert( - LocalAssetEntityCompanion.insert( - id: id, - name: 'asset_$id.jpg', - checksum: Value(checksum), - type: type, - createdAt: Value(created), - updatedAt: Value(created), - isFavorite: Value(isFavorite), - iCloudId: Value(iCloudId), - adjustmentTime: Value(adjustmentTime), - latitude: Value(latitude), - longitude: Value(longitude), - ), - ); - } - - Future insertRemoteAsset({ - required String id, - required String checksum, - required String ownerId, - DateTime? deletedAt, - }) async { - await db - .into(db.remoteAssetEntity) - .insert( - RemoteAssetEntityCompanion.insert( - id: id, - name: 'remote_$id.jpg', - checksum: checksum, - type: AssetType.image, - createdAt: Value(now), - updatedAt: Value(now), - ownerId: ownerId, - visibility: AssetVisibility.timeline, - deletedAt: Value(deletedAt), - ), - ); - } - - Future insertRemoteAssetCloudId({ - required String assetId, - required String? cloudId, - DateTime? createdAt, - DateTime? adjustmentTime, - double? latitude, - double? longitude, - }) async { - await db - .into(db.remoteAssetCloudIdEntity) - .insert( - RemoteAssetCloudIdEntityCompanion.insert( - assetId: assetId, - cloudId: Value(cloudId), - createdAt: Value(createdAt), - adjustmentTime: Value(adjustmentTime), - latitude: Value(latitude), - longitude: Value(longitude), - ), - ); - } - - Future insertUser(String id, String email) async { - await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email)); - } - group('getRemovalCandidates', () { - final userId = 'user-123'; - final otherUserId = 'user-456'; - final cutoffDate = DateTime(2024, 1, 10); - final beforeCutoff = DateTime(2024, 1, 5); - final afterCutoff = DateTime(2024, 1, 12); + final cutoffDate = DateTime(2024, 1, 1); + final beforeCutoff = DateTime(2023, 12, 31); + final afterCutoff = DateTime(2024, 1, 2); + late String userId; setUp(() async { - await insertUser(userId, 'user@test.com'); - await insertUser(otherUserId, 'other@test.com'); + final user = await ctx.newUser(); + userId = user.id; }); - Future insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async { - await db - .into(db.localAlbumEntity) - .insert( - LocalAlbumEntityCompanion.insert( - id: id, - name: name, - updatedAt: Value(now), - backupSelection: BackupSelection.none, - isIosSharedAlbum: Value(isIosSharedAlbum), - ), - ); - } - - Future insertLocalAlbumAsset({required String albumId, required String assetId}) async { - await db - .into(db.localAlbumAssetEntity) - .insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId)); - } - test('returns only assets that match all criteria', () async { + final otherUser = await ctx.newUser(); + // Asset 1: Should be included - backed up, before cutoff, correct owner, not deleted, not favorite - await insertLocalAsset( - id: 'local-1', - checksum: 'checksum-1', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId); + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final includedAsset = await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); // Asset 2: Should NOT be included - not backed up (no remote asset) - await insertLocalAsset( - id: 'local-2', - checksum: 'checksum-2', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); + await ctx.newLocalAsset(createdAt: beforeCutoff); // Asset 3: Should NOT be included - after cutoff date - await insertLocalAsset( - id: 'local-3', - checksum: 'checksum-3', - createdAt: afterCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId); + await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: afterCutoff); // Asset 4: Should NOT be included - different owner - await insertLocalAsset( - id: 'local-4', - checksum: 'checksum-4', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-4', checksum: 'checksum-4', ownerId: otherUserId); + final otherRemoteAsset = await ctx.newRemoteAsset(ownerId: otherUser.id); + await ctx.newLocalAsset(checksum: otherRemoteAsset.checksum, createdAt: beforeCutoff); // Asset 5: Should NOT be included - remote asset is deleted - await insertLocalAsset( - id: 'local-5', - checksum: 'checksum-5', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-5', checksum: 'checksum-5', ownerId: userId, deletedAt: now); + final deletedAsset = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2024, 1, 1)); + await ctx.newLocalAsset(checksum: deletedAsset.checksum, createdAt: beforeCutoff); // Asset 6: Should NOT be included - is favorite (when keepFavorites=true) - await insertLocalAsset( - id: 'local-6', - checksum: 'checksum-6', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: true, - ); - await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + final favoriteAsset = await ctx.newRemoteAsset(ownerId: userId, isFavorite: true); + await ctx.newLocalAsset(checksum: favoriteAsset.checksum, createdAt: beforeCutoff, isFavorite: true); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-1'); + expect(result.assets.first.id, includedAsset.id); }); test('includes favorites when keepFavorites is false', () async { - await insertLocalAsset( - id: 'local-favorite', - checksum: 'checksum-fav', + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final favoriteAsset = await ctx.newLocalAsset( + checksum: remoteAsset.checksum, createdAt: beforeCutoff, - type: AssetType.image, isFavorite: true, ); - await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepFavorites: false); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-favorite'); - expect(result.assets[0].isFavorite, true); + expect(result.assets.first.id, favoriteAsset.id); + expect(result.assets.first.isFavorite, true); + }); + + test('excludes asset when both local and remote are favorites', () async { + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId, isFavorite: true); + await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff, isFavorite: true); + + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + expect(result.assets, isEmpty); + }); + + test('excludes asset when only local is favorite', () async { + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff, isFavorite: true); + + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + expect(result.assets, isEmpty); + }); + + test('excludes asset when only remote is favorite', () async { + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId, isFavorite: true); + await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); + + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + expect(result.assets, isEmpty); + }); + + test('includes asset when neither local nor remote is favorite', () async { + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final localAsset = await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); + + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + expect(result.assets.length, 1); + expect(result.assets.first.id, localAsset.id); }); test('keepMediaType photosOnly returns only videos for deletion', () async { + final photoAsset = await ctx.newRemoteAsset(ownerId: userId); // Photo - should be kept - await insertLocalAsset( - id: 'local-photo', - checksum: 'checksum-photo', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); + await ctx.newLocalAsset(checksum: photoAsset.checksum, createdAt: beforeCutoff); + final videoRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); // Video - should be deleted - await insertLocalAsset( - id: 'local-video', - checksum: 'checksum-video', + final videoLocalAsset = await ctx.newLocalAsset( + checksum: videoRemoteAsset.checksum, createdAt: beforeCutoff, type: AssetType.video, - isFavorite: false, ); - await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-video'); - expect(result.assets[0].type, AssetType.video); + expect(result.assets.first.id, videoLocalAsset.id); + expect(result.assets.first.type, AssetType.video); }); test('keepMediaType videosOnly returns only photos for deletion', () async { // Photo - should be deleted - await insertLocalAsset( - id: 'local-photo', - checksum: 'checksum-photo', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); + final photoRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final photoAsset = await ctx.newLocalAsset(checksum: photoRemoteAsset.checksum, createdAt: beforeCutoff); // Video - should be kept - await insertLocalAsset( - id: 'local-video', - checksum: 'checksum-video', - createdAt: beforeCutoff, - type: AssetType.video, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.videosOnly); + final videoRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + await ctx.newLocalAsset(checksum: videoRemoteAsset.checksum, createdAt: beforeCutoff, type: AssetType.video); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.videosOnly); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-photo'); - expect(result.assets[0].type, AssetType.image); + expect(result.assets.first.id, photoAsset.id); + expect(result.assets.first.type, AssetType.image); }); test('returns both photos and videos with keepMediaType.all', () async { // Photo - await insertLocalAsset( - id: 'local-photo', - checksum: 'checksum-photo', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); + final photoRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final photoAsset = await ctx.newLocalAsset(checksum: photoRemoteAsset.checksum, createdAt: beforeCutoff); // Video - await insertLocalAsset( - id: 'local-video', - checksum: 'checksum-video', + final videoRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final videoAsset = await ctx.newLocalAsset( + checksum: videoRemoteAsset.checksum, createdAt: beforeCutoff, type: AssetType.video, - isFavorite: false, ); - await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.none); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.none); expect(result.assets.length, 2); final ids = result.assets.map((a) => a.id).toSet(); - expect(ids, containsAll(['local-photo', 'local-video'])); + expect(ids, containsAll([photoAsset.id, videoAsset.id])); }); test('excludes assets in iOS shared albums', () async { // Regular album - await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + final regularAlbum = await ctx.newLocalAlbum(); // iOS shared album - await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true); + final sharedAlbum = await ctx.newLocalAlbum(isIosSharedAlbum: true); // Asset in regular album (should be included) - await insertLocalAsset( - id: 'local-regular', - checksum: 'checksum-regular', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-regular', checksum: 'checksum-regular', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-regular'); + final regularRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final regularAsset = await ctx.newLocalAsset(checksum: regularRemoteAsset.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: regularAlbum.id, assetId: regularAsset.id); // Asset in iOS shared album (should be excluded) - await insertLocalAsset( - id: 'local-shared', - checksum: 'checksum-shared', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-shared', checksum: 'checksum-shared', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-shared'); - - final result = await repository.getRemovalCandidates(userId, cutoffDate); + final sharedRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final sharedAsset = await ctx.newLocalAsset(checksum: sharedRemoteAsset.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: sharedAlbum.id, assetId: sharedAsset.id); + final result = await sut.getRemovalCandidates(userId, cutoffDate); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-regular'); + expect(result.assets.first.id, regularAsset.id); }); test('includes assets at exact cutoff date', () async { - await insertLocalAsset( - id: 'local-exact', - checksum: 'checksum-exact', - createdAt: cutoffDate, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate); + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final localAsset = await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: cutoffDate); + final result = await sut.getRemovalCandidates(userId, cutoffDate); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-exact'); + expect(result.assets.first.id, localAsset.id); }); test('returns empty list when no assets match criteria', () async { // Only assets after cutoff - await insertLocalAsset( - id: 'local-after', - checksum: 'checksum-after', - createdAt: afterCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate); + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: afterCutoff); + final result = await sut.getRemovalCandidates(userId, cutoffDate); expect(result.assets, isEmpty); }); test('handles multiple assets with same checksum', () async { // Two local assets with same checksum (edge case, but should handle it) - await insertLocalAsset( - id: 'local-dup1', - checksum: 'checksum-dup', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertLocalAsset( - id: 'local-dup2', - checksum: 'checksum-dup', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate); + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); + await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); + final result = await sut.getRemovalCandidates(userId, cutoffDate); expect(result.assets.length, 2); - expect(result.assets.map((a) => a.checksum).toSet(), equals({'checksum-dup'})); + expect(result.assets.map((a) => a.checksum).toSet(), equals({remoteAsset.checksum})); }); test('includes assets not in any album', () async { // Asset not in any album should be included - await insertLocalAsset( - id: 'local-no-album', - checksum: 'checksum-no-album', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId); - - final result = await repository.getRemovalCandidates(userId, cutoffDate); + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final localAsset = await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); + final result = await sut.getRemovalCandidates(userId, cutoffDate); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-no-album'); + expect(result.assets.first.id, localAsset.id); }); test('excludes asset that is in both regular and iOS shared album', () async { // Regular album - await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + final regularAlbum = await ctx.newLocalAlbum(); // iOS shared album - await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true); + final sharedAlbum = await ctx.newLocalAlbum(isIosSharedAlbum: true); // Asset in BOTH albums - should be excluded because it's in an iOS shared album - await insertLocalAsset( - id: 'local-both', - checksum: 'checksum-both', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-both'); - await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both'); - - final result = await repository.getRemovalCandidates(userId, cutoffDate); + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final localAsset = await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: regularAlbum.id, assetId: localAsset.id); + await ctx.newLocalAlbumAsset(albumId: sharedAlbum.id, assetId: localAsset.id); + final result = await sut.getRemovalCandidates(userId, cutoffDate); expect(result.assets, isEmpty); }); test('excludes assets with null checksum (not backed up)', () async { // Asset with null checksum cannot be matched to remote asset - await db - .into(db.localAssetEntity) - .insert( - LocalAssetEntityCompanion.insert( - id: 'local-null-checksum', - name: 'asset_null.jpg', - checksum: const Value.absent(), // null checksum - type: AssetType.image, - createdAt: Value(beforeCutoff), - updatedAt: Value(beforeCutoff), - isFavorite: const Value(false), - ), - ); - - final result = await repository.getRemovalCandidates(userId, cutoffDate); + await ctx.newLocalAsset(checksumOption: const Option.none()); + final result = await sut.getRemovalCandidates(userId, cutoffDate); expect(result.assets, isEmpty); }); test('excludes assets in user-excluded albums', () async { // Create two regular albums - await insertLocalAlbum(id: 'album-include', name: 'Include Album', isIosSharedAlbum: false); - await insertLocalAlbum(id: 'album-exclude', name: 'Exclude Album', isIosSharedAlbum: false); + final includeAlbum = await ctx.newLocalAlbum(); + final excludeAlbum = await ctx.newLocalAlbum(); // Asset in included album - should be included - await insertLocalAsset( - id: 'local-in-included', - checksum: 'checksum-included', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-included', checksum: 'checksum-included', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-include', assetId: 'local-in-included'); + final includedRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final includedAsset = await ctx.newLocalAsset(checksum: includedRemoteAsset.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: includeAlbum.id, assetId: includedAsset.id); // Asset in excluded album - should NOT be included - await insertLocalAsset( - id: 'local-in-excluded', - checksum: 'checksum-excluded', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-excluded', checksum: 'checksum-excluded', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-exclude', assetId: 'local-in-excluded'); + final excludedRemoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final excludedAsset = await ctx.newLocalAsset(checksum: excludedRemoteAsset.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: excludeAlbum.id, assetId: excludedAsset.id); - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-exclude'}); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {excludeAlbum.id}); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-in-included'); + expect(result.assets.first.id, includedAsset.id); }); test('excludes assets that are in any of multiple excluded albums', () async { // Create multiple albums - await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false); - await insertLocalAlbum(id: 'album-2', name: 'Album 2', isIosSharedAlbum: false); - await insertLocalAlbum(id: 'album-3', name: 'Album 3', isIosSharedAlbum: false); + final album1 = await ctx.newLocalAlbum(); + final album2 = await ctx.newLocalAlbum(); + final album3 = await ctx.newLocalAlbum(); // Asset in album-1 (excluded) - should NOT be included - await insertLocalAsset( - id: 'local-1', - checksum: 'checksum-1', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1'); + final remote1 = await ctx.newRemoteAsset(ownerId: userId); + final local1 = await ctx.newLocalAsset(checksum: remote1.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: local1.id); // Asset in album-2 (excluded) - should NOT be included - await insertLocalAsset( - id: 'local-2', - checksum: 'checksum-2', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-2', assetId: 'local-2'); + final remote2 = await ctx.newRemoteAsset(ownerId: userId); + final local2 = await ctx.newLocalAsset(checksum: remote2.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: local2.id); // Asset in album-3 (not excluded) - should be included - await insertLocalAsset( - id: 'local-3', - checksum: 'checksum-3', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3'); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-1', 'album-2'}); + final remote3 = await ctx.newRemoteAsset(ownerId: userId); + final local3 = await ctx.newLocalAsset(checksum: remote3.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: album3.id, assetId: local3.id); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {album1.id, album2.id}); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-3'); + expect(result.assets.first.id, local3.id); }); test('excludes asset that is in both excluded and non-excluded album', () async { - await insertLocalAlbum(id: 'album-included', name: 'Included Album', isIosSharedAlbum: false); - await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false); + final includedAlbum = await ctx.newLocalAlbum(); + final excludedAlbum = await ctx.newLocalAlbum(); // Asset in BOTH albums - should be excluded because it's in an excluded album - await insertLocalAsset( - id: 'local-both', - checksum: 'checksum-both', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-included', assetId: 'local-both'); - await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-both'); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'}); + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final localAsset = await ctx.newLocalAsset(checksum: remoteAsset.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: includedAlbum.id, assetId: localAsset.id); + await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: localAsset.id); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {excludedAlbum.id}); expect(result.assets, isEmpty); }); test('includes all assets when excludedAlbumIds is empty', () async { - await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false); + final album1 = await ctx.newLocalAlbum(); - await insertLocalAsset( - id: 'local-1', - checksum: 'checksum-1', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1'); + final remote1 = await ctx.newRemoteAsset(ownerId: userId); + final local1 = await ctx.newLocalAsset(checksum: remote1.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: local1.id); - await insertLocalAsset( - id: 'local-2', - checksum: 'checksum-2', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId); + final remote2 = await ctx.newRemoteAsset(ownerId: userId); + await ctx.newLocalAsset(checksum: remote2.checksum, createdAt: beforeCutoff); // Empty excludedAlbumIds should include all eligible assets - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {}); - + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {}); expect(result.assets.length, 2); }); test('excludes asset not in any album when album is excluded', () async { - await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false); + final excludedAlbum = await ctx.newLocalAlbum(); // Asset NOT in any album - should be included - await insertLocalAsset( - id: 'local-no-album', - checksum: 'checksum-no-album', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId); + final noAlbumRemote = await ctx.newRemoteAsset(ownerId: userId); + final noAlbumAsset = await ctx.newLocalAsset(checksum: noAlbumRemote.checksum, createdAt: beforeCutoff); // Asset in excluded album - should NOT be included - await insertLocalAsset( - id: 'local-in-excluded', - checksum: 'checksum-in-excluded', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-in-excluded', checksum: 'checksum-in-excluded', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-in-excluded'); - - final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'}); + final excludedRemote = await ctx.newRemoteAsset(ownerId: userId); + final excludedAsset = await ctx.newLocalAsset(checksum: excludedRemote.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: excludedAsset.id); + final result = await sut.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {excludedAlbum.id}); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-no-album'); + expect(result.assets.first.id, noAlbumAsset.id); }); test('combines excludedAlbumIds with keepMediaType correctly', () async { - await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false); - await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + final excludedAlbum = await ctx.newLocalAlbum(); + final regularAlbum = await ctx.newLocalAlbum(); // Photo in excluded album - should NOT be included (album excluded) - await insertLocalAsset( - id: 'local-photo-excluded', - checksum: 'checksum-photo-excluded', + final photoExcludedRemote = await ctx.newRemoteAsset(ownerId: userId); + final photoExcludedAsset = await ctx.newLocalAsset( + checksum: photoExcludedRemote.checksum, createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, ); - await insertRemoteAsset(id: 'remote-photo-excluded', checksum: 'checksum-photo-excluded', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-photo-excluded'); + await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: photoExcludedAsset.id); // Video in regular album - should be included (keepMediaType photosOnly = delete videos) - await insertLocalAsset( - id: 'local-video', - checksum: 'checksum-video', + final videoRemote = await ctx.newRemoteAsset(ownerId: userId); + final videoAsset = await ctx.newLocalAsset( + checksum: videoRemote.checksum, createdAt: beforeCutoff, type: AssetType.video, - isFavorite: false, ); - await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-video'); + await ctx.newLocalAlbumAsset(albumId: regularAlbum.id, assetId: videoAsset.id); // Photo in regular album - should NOT be included (keepMediaType photosOnly = keep photos) - await insertLocalAsset( - id: 'local-photo-regular', - checksum: 'checksum-photo-regular', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-photo-regular', checksum: 'checksum-photo-regular', ownerId: userId); - await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-photo-regular'); + final photoRegularRemote = await ctx.newRemoteAsset(ownerId: userId); + final photoRegularAsset = await ctx.newLocalAsset(checksum: photoRegularRemote.checksum, createdAt: beforeCutoff); + await ctx.newLocalAlbumAsset(albumId: regularAlbum.id, assetId: photoRegularAsset.id); - final result = await repository.getRemovalCandidates( + final result = await sut.getRemovalCandidates( userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly, - keepAlbumIds: {'album-excluded'}, + keepAlbumIds: {excludedAlbum.id}, ); expect(result.assets.length, 1); - expect(result.assets[0].id, 'local-video'); + expect(result.assets.first.id, videoAsset.id); }); }); group('reconcileHashesFromCloudId', () { - final userId = 'user-123'; - final createdAt = DateTime(2024, 1, 10); - final adjustmentTime = DateTime(2024, 1, 11); - const latitude = 37.7749; - const longitude = -122.4194; + late String userId; setUp(() async { - await insertUser(userId, 'user@test.com'); + final user = await ctx.newUser(); + userId = user.id; }); test('updates local asset checksum when all metadata matches', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final remoteCloudAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: remoteCloudAsset.cloudId, + createdAt: remoteCloudAsset.createdAt, + adjustmentTime: remoteCloudAsset.adjustmentTime, + latitude: remoteCloudAsset.latitude, + longitude: remoteCloudAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); - expect(updated?.checksum, 'hash-abc123'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); + expect(updated?.checksum, remoteAsset.checksum); }); test('does not update when local asset already has checksum', () async { - await insertLocalAsset( - id: 'local-1', - checksum: 'existing-checksum', - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final remoteCloudAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id); + + final localAsset = await ctx.newLocalAsset( + checksum: 'existing', + iCloudId: remoteCloudAsset.cloudId, + createdAt: remoteCloudAsset.createdAt, + adjustmentTime: remoteCloudAsset.adjustmentTime, + latitude: remoteCloudAsset.latitude, + longitude: remoteCloudAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); - expect(updated?.checksum, 'existing-checksum'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); + expect(updated?.checksum, 'existing'); }); test('does not update when adjustment_time does not match', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, adjustmentTime: DateTime(2024, 1, 12)); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: cloudIdAsset.cloudId, + createdAt: cloudIdAsset.createdAt, + adjustmentTime: DateTime(2026, 1, 12), + latitude: cloudIdAsset.latitude, + longitude: cloudIdAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: DateTime(2024, 1, 12), - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); test('does not update when latitude does not match', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); - - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, latitude: const Option.none()); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: cloudIdAsset.cloudId, + createdAt: cloudIdAsset.createdAt, + adjustmentTime: cloudIdAsset.adjustmentTime, latitude: 40.7128, - longitude: longitude, + longitude: cloudIdAsset.longitude, ); - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); test('does not update when longitude does not match', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, longitude: (-74.006).toOption()); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: cloudIdAsset.cloudId, + createdAt: cloudIdAsset.createdAt, + adjustmentTime: cloudIdAsset.adjustmentTime, + latitude: cloudIdAsset.latitude, + longitude: 0.0, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: -74.0060, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); test('does not update when createdAt does not match', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, createdAt: DateTime(2024, 1, 5)); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: cloudIdAsset.cloudId, + createdAt: DateTime(2024, 6, 1), + adjustmentTime: cloudIdAsset.adjustmentTime, + latitude: cloudIdAsset.latitude, + longitude: cloudIdAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: DateTime(2024, 1, 5), - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); test('does not update when iCloudId is null', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), iCloudId: null, - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, + createdAt: cloudIdAsset.createdAt, + adjustmentTime: cloudIdAsset.adjustmentTime, + latitude: cloudIdAsset.latitude, + longitude: cloudIdAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); test('does not update when cloudId does not match iCloudId', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: 'different-cloud-id', + createdAt: cloudIdAsset.createdAt, + adjustmentTime: cloudIdAsset.adjustmentTime, + latitude: cloudIdAsset.latitude, + longitude: cloudIdAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-456', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); test('handles partial null metadata fields matching correctly', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: null, - latitude: latitude, - longitude: longitude, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId( + id: remoteAsset.id, + adjustmentTimeOption: const Option.none(), + ); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: cloudIdAsset.cloudId, + createdAt: cloudIdAsset.createdAt, + adjustmentTimeOption: const Option.none(), + latitude: cloudIdAsset.latitude, + longitude: cloudIdAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: null, - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); - expect(updated?.checksum, 'hash-abc123'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); + expect(updated?.checksum, remoteAsset.checksum); }); test('does not update when one has null and other has value', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, + final remoteAsset = await ctx.newRemoteAsset(ownerId: userId); + final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id); + final localAsset = await ctx.newLocalAsset( + checksumOption: const Option.none(), + iCloudId: cloudIdAsset.cloudId, + createdAt: cloudIdAsset.createdAt, + adjustmentTime: cloudIdAsset.adjustmentTime, latitude: null, - longitude: longitude, + longitude: cloudIdAsset.longitude, ); - await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId); - - await insertRemoteAssetCloudId( - assetId: 'remote-1', - cloudId: 'cloud-123', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); - - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); test('handles no matching assets gracefully', () async { - await insertLocalAsset( - id: 'local-1', - checksum: null, - iCloudId: 'cloud-999', - createdAt: createdAt, - adjustmentTime: adjustmentTime, - latitude: latitude, - longitude: longitude, - ); + final localAsset = await ctx.newLocalAsset(checksumOption: const Option.none(), iCloudId: 'cloud-no-match'); - await repository.reconcileHashesFromCloudId(); - - final updated = await repository.getById('local-1'); + await sut.reconcileHashesFromCloudId(); + final updated = await sut.getById(localAsset.id); expect(updated?.checksum, isNull); }); }); diff --git a/mobile/test/infrastructure/repositories/remote_album_repository_test.dart b/mobile/test/infrastructure/repositories/remote_album_repository_test.dart new file mode 100644 index 0000000000..1bc797f6e1 --- /dev/null +++ b/mobile/test/infrastructure/repositories/remote_album_repository_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; + +import '../../medium/repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late DriftRemoteAlbumRepository sut; + + setUp(() async { + ctx = MediumRepositoryContext(); + sut = DriftRemoteAlbumRepository(ctx.db); + }); + + tearDown(() async { + await ctx.dispose(); + }); + + group('getSortedAlbumIds', () { + late String userId; + + setUp(() async { + final user = await ctx.newUser(); + userId = user.id; + }); + + test('returns empty list when albumIds is empty', () async { + final result = await sut.getSortedAlbumIds([], aggregation: AssetDateAggregation.start); + expect(result, isEmpty); + }); + + test('returns single album when only one album exists', () async { + final album = await ctx.newRemoteAlbum(ownerId: userId); + final asset = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1)); + await ctx.insertRemoteAlbumAsset(albumId: album.id, assetId: asset.id); + + final result = await sut.getSortedAlbumIds([album.id], aggregation: AssetDateAggregation.start); + expect(result, [album.id]); + }); + + test('sorts albums by start date (MIN) ascending', () async { + // Album 1: Assets from Jan 10 to Jan 20 (start: Jan 10) + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); + final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20)); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id); + + // Album 2: Assets from Jan 5 to Jan 15 (start: Jan 5) + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); + final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id); + + // Album 3: Assets from Jan 25 to Jan 30 (start: Jan 25) + final album3 = await ctx.newRemoteAlbum(ownerId: userId); + final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25)); + final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30)); + await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id); + await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id); + + final result = await sut.getSortedAlbumIds([ + album1.id, + album2.id, + album3.id, + ], aggregation: AssetDateAggregation.start); + + // Expected order: album2 (Jan 5), album1 (Jan 10), album3 (Jan 25) + expect(result, [album2.id, album1.id, album3.id]); + }); + + test('sorts albums by end date (MAX) ascending', () async { + // Album 1: Assets from Jan 10 to Jan 20 (end: Jan 20) + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); + final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20)); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id); + + // Album 2: Assets from Jan 5 to Jan 15 (end: Jan 15) + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); + final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id); + + // Album 3: Assets from Jan 25 to Jan 30 (end: Jan 30) + final album3 = await ctx.newRemoteAlbum(ownerId: userId); + final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25)); + final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30)); + await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id); + await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id); + + final result = await sut.getSortedAlbumIds([ + album1.id, + album2.id, + album3.id, + ], aggregation: AssetDateAggregation.end); + + // Expected order: album2 (Jan 15), album1 (Jan 20), album3 (Jan 30) + expect(result, [album2.id, album1.id, album3.id]); + }); + + test('handles albums with single asset', () async { + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); + + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); + + final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); + + expect(result, [album2.id, album1.id]); + }); + + test('only returns requested album IDs in the result', () async { + // Create 3 albums + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); + + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); + + final album3 = await ctx.newRemoteAlbum(ownerId: userId); + final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); + await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id); + + // Only request album1 and album3 + final result = await sut.getSortedAlbumIds([album1.id, album3.id], aggregation: AssetDateAggregation.start); + + // Should only return album1 and album3, not album2 + expect(result, [album1.id, album3.id]); + }); + + test('handles albums with same date correctly', () async { + final sameDate = DateTime(2024, 1, 10); + + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); + + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); + + final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); + + // Both albums have the same date, so both should be returned + expect(result, hasLength(2)); + expect(result, containsAll([album1.id, album2.id])); + }); + + test('handles albums across different years', () async { + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2023, 12, 25)); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); + + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); + + final album3 = await ctx.newRemoteAlbum(ownerId: userId); + final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2025, 1, 1)); + await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id); + + final result = await sut.getSortedAlbumIds([ + album1.id, + album2.id, + album3.id, + ], aggregation: AssetDateAggregation.start); + + expect(result, [album1.id, album2.id, album3.id]); + }); + + test('handles album with multiple assets correctly', () async { + final album1 = await ctx.newRemoteAlbum(ownerId: userId); + // Album 1 has 5 assets from Jan 5 to Jan 25 + final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); + final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); + final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); + final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20)); + final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25)); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset3.id); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset4.id); + await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset5.id); + + final album2 = await ctx.newRemoteAlbum(ownerId: userId); + final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1)); + await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset6.id); + + final resultStart = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); + + // album2 (Jan 1) should come before album1 (Jan 5) + expect(resultStart, [album2.id, album1.id]); + + final resultEnd = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.end); + + // album2 (Jan 1) should come before album1 (Jan 25) + expect(resultEnd, [album2.id, album1.id]); + }); + }); +} diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 660b8206bb..62aae4c0da 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; +import 'package:immich_mobile/utils/semver.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; @@ -72,8 +73,14 @@ void main() { Future streamChanges( Future Function(List, Function() abort, Function() reset) onDataCallback, + SemVer serverVersion, ) { - return sut.streamChanges(onDataCallback, batchSize: testBatchSize, httpClient: mockHttpClient); + return sut.streamChanges( + onDataCallback, + batchSize: testBatchSize, + httpClient: mockHttpClient, + serverVersion: serverVersion, + ); } test('streamChanges stops processing stream when abort is called', () async { @@ -94,7 +101,7 @@ void main() { } } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); // Give the stream subscription time to start (longer delay to account for mock delay) await Future.delayed(const Duration(milliseconds: 50)); @@ -145,7 +152,7 @@ void main() { } } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); await Future.delayed(const Duration(milliseconds: 50)); @@ -197,7 +204,7 @@ void main() { } } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); await Future.delayed(const Duration(milliseconds: 50)); @@ -244,7 +251,7 @@ void main() { onDataCallCount++; } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); await Future.delayed(const Duration(milliseconds: 50)); @@ -271,7 +278,7 @@ void main() { onDataCallCount++; } - final future = streamChanges(onDataCallback); + final future = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); errorBodyController.add(utf8.encode('{"error":"Unauthorized"}')); await errorBodyController.close(); diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart new file mode 100644 index 0000000000..2c4758400c --- /dev/null +++ b/mobile/test/medium/repository_context.dart @@ -0,0 +1,246 @@ +import 'dart:math'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; +import 'package:uuid/uuid.dart'; + +class MediumRepositoryContext { + final Drift db; + final Random _random = Random(); + + MediumRepositoryContext() : db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + + Future dispose() async { + await db.close(); + } + + static Value _resolveUndefined(T? plain, Option? option, T fallback) { + if (plain != null) { + return Value(plain); + } + + return _resolveOption(option, fallback); + } + + static Value _resolveOption(Option? option, T fallback) { + if (option != null) { + return option.fold(Value.new, Value.absent); + } + + return Value(fallback); + } + + Future newUser({ + String? id, + String? email, + AvatarColor? avatarColor, + DateTime? profileChangedAt, + bool? hasProfileImage, + }) async { + id = id ?? const Uuid().v4(); + return await db + .into(db.userEntity) + .insertReturning( + UserEntityCompanion( + id: Value(id), + email: Value(email ?? '$id@test.com'), + name: Value(email ?? 'user_$id'), + avatarColor: Value(avatarColor ?? AvatarColor.values[_random.nextInt(AvatarColor.values.length)]), + profileChangedAt: Value(profileChangedAt ?? DateTime.now()), + hasProfileImage: Value(hasProfileImage ?? false), + ), + ); + } + + Future newRemoteAsset({ + String? id, + String? checksum, + String? ownerId, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? deletedAt, + AssetType? type, + AssetVisibility? visibility, + int? durationInSeconds, + int? width, + int? height, + bool? isFavorite, + bool? isEdited, + String? livePhotoVideoId, + String? stackId, + String? thumbHash, + String? libraryId, + }) async { + id = id ?? const Uuid().v4(); + createdAt = createdAt ?? DateTime.now(); + return db + .into(db.remoteAssetEntity) + .insertReturning( + RemoteAssetEntityCompanion( + id: Value(id), + name: Value('remote_$id.jpg'), + checksum: Value(checksum ?? const Uuid().v4()), + type: Value(type ?? AssetType.image), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt ?? DateTime.now()), + ownerId: Value(ownerId ?? const Uuid().v4()), + visibility: Value(visibility ?? AssetVisibility.timeline), + deletedAt: Value(deletedAt), + durationInSeconds: Value(durationInSeconds ?? 0), + width: Value(width ?? _random.nextInt(1000)), + height: Value(height ?? _random.nextInt(1000)), + isFavorite: Value(isFavorite ?? false), + isEdited: Value(isEdited ?? false), + livePhotoVideoId: Value(livePhotoVideoId), + stackId: Value(stackId), + localDateTime: Value(createdAt.toLocal()), + thumbHash: Value(thumbHash ?? const Uuid().v4()), + libraryId: Value(libraryId ?? const Uuid().v4()), + ), + ); + } + + Future newRemoteAssetCloudId({ + String? id, + String? cloudId, + DateTime? createdAt, + DateTime? adjustmentTime, + Option? adjustmentTimeOption, + Option? latitude, + Option? longitude, + }) { + return db + .into(db.remoteAssetCloudIdEntity) + .insertReturning( + RemoteAssetCloudIdEntityCompanion( + assetId: Value(id ?? const Uuid().v4()), + cloudId: Value(cloudId ?? const Uuid().v4()), + createdAt: Value(createdAt ?? DateTime.now()), + adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()), + latitude: _resolveOption(latitude, _random.nextDouble() * 180 - 90), + longitude: _resolveOption(longitude, _random.nextDouble() * 360 - 180), + ), + ); + } + + Future newRemoteAlbum({ + String? id, + String? name, + String? ownerId, + DateTime? createdAt, + DateTime? updatedAt, + String? description, + bool? isActivityEnabled, + AlbumAssetOrder? order, + String? thumbnailAssetId, + }) async { + id = id ?? const Uuid().v4(); + return db + .into(db.remoteAlbumEntity) + .insertReturning( + RemoteAlbumEntityCompanion( + id: Value(id), + name: Value(name ?? 'remote_album_$id'), + ownerId: Value(ownerId ?? const Uuid().v4()), + createdAt: Value(createdAt ?? DateTime.now()), + updatedAt: Value(updatedAt ?? DateTime.now()), + description: Value(description ?? 'Description for album $id'), + isActivityEnabled: Value(isActivityEnabled ?? false), + order: Value(order ?? AlbumAssetOrder.asc), + thumbnailAssetId: Value(thumbnailAssetId), + ), + ); + } + + Future insertRemoteAlbumAsset({required String albumId, required String assetId}) { + return db + .into(db.remoteAlbumAssetEntity) + .insert(RemoteAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId)); + } + + Future newLocalAsset({ + String? id, + String? name, + String? checksum, + Option? checksumOption, + DateTime? createdAt, + AssetType? type, + bool? isFavorite, + String? iCloudId, + DateTime? adjustmentTime, + Option? adjustmentTimeOption, + double? latitude, + double? longitude, + int? width, + int? height, + int? durationInSeconds, + int? orientation, + DateTime? updatedAt, + }) async { + id = id ?? const Uuid().v4(); + return db + .into(db.localAssetEntity) + .insertReturning( + LocalAssetEntityCompanion( + id: Value(id), + name: Value(name ?? 'local_$id.jpg'), + height: Value(height ?? _random.nextInt(1000)), + width: Value(width ?? _random.nextInt(1000)), + durationInSeconds: Value(durationInSeconds ?? 0), + orientation: Value(orientation ?? 0), + updatedAt: Value(updatedAt ?? DateTime.now()), + checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()), + createdAt: Value(createdAt ?? DateTime.now()), + type: Value(type ?? AssetType.image), + isFavorite: Value(isFavorite ?? false), + iCloudId: Value(iCloudId ?? const Uuid().v4()), + adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()), + latitude: Value(latitude ?? _random.nextDouble() * 180 - 90), + longitude: Value(longitude ?? _random.nextDouble() * 360 - 180), + ), + ); + } + + Future newLocalAlbum({ + String? id, + String? name, + DateTime? updatedAt, + BackupSelection? backupSelection, + bool? isIosSharedAlbum, + String? linkedRemoteAlbumId, + }) { + id = id ?? const Uuid().v4(); + return db + .into(db.localAlbumEntity) + .insertReturning( + LocalAlbumEntityCompanion( + id: Value(id), + name: Value(name ?? 'local_album_$id'), + updatedAt: Value(updatedAt ?? DateTime.now()), + backupSelection: Value(backupSelection ?? BackupSelection.none), + isIosSharedAlbum: Value(isIosSharedAlbum ?? false), + linkedRemoteAlbumId: Value(linkedRemoteAlbumId), + ), + ); + } + + Future newLocalAlbumAsset({required String albumId, required String assetId}) { + return db + .into(db.localAlbumAssetEntity) + .insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId)); + } +} 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/mobile/test/utils/option_test.dart b/mobile/test/utils/option_test.dart new file mode 100644 index 0000000000..4fa44a3865 --- /dev/null +++ b/mobile/test/utils/option_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/option.dart'; + +void main() { + group('Option', () { + group('constructors', () { + test('Option.some creates a Some instance', () { + const option = Option.some(42); + expect(option, isA>()); + expect((option as Some).value, 42); + }); + + test('Option.none creates a None instance', () { + const option = Option.none(); + expect(option, isA>()); + }); + + test('Option.fromNullable returns Some for non-null value', () { + final option = Option.fromNullable('hello'); + expect(option, isA>()); + expect((option as Some).value, 'hello'); + }); + + test('Option.fromNullable returns None for null value', () { + final option = Option.fromNullable(null); + expect(option, isA()); + }); + }); + + group('isSome / isNone', () { + test('Some.isSome is true', () { + expect(const Option.some(1).isSome, isTrue); + }); + + test('Some.isNone is false', () { + expect(const Option.some(1).isNone, isFalse); + }); + + test('None.isSome is false', () { + expect(const Option.none().isSome, isFalse); + }); + + test('None.isNone is true', () { + expect(const Option.none().isNone, isTrue); + }); + }); + + group('unwrapOrNull', () { + test('returns value for Some', () { + expect(const Option.some('hi').unwrapOrNull, 'hi'); + }); + + test('returns null for None', () { + expect(const Option.none().unwrapOrNull, isNull); + }); + }); + + group('fold', () { + test('calls onSome with value for Some', () { + final result = const Option.some('world').fold((v) => 'some: $v', () => 'none'); + expect(result, 'some: world'); + }); + + test('calls onNone for None', () { + final result = const Option.none().fold((v) => 'some: $v', () => 'none'); + expect(result, 'none'); + }); + }); + + group('equality', () { + test('Some equals Some with same value', () { + expect(const Option.some(1) == const Option.some(1), isTrue); + }); + + test('Some does not equal Some with different value', () { + expect(const Option.some(1) == const Option.some(2), isFalse); + }); + + test('None equals None of same type', () { + expect(const Option.none() == const Option.none(), isTrue); + }); + + test('None does not equal None of different type', () { + expect(const Option.none() == (const Option.none() as Object), isFalse); + }); + + test('Some does not equal None', () { + expect(const Option.some(0) == const Option.none(), isFalse); + }); + }); + + group('hashCode', () { + test('Some hashCode equals value hashCode', () { + expect(const Option.some('abc').hashCode, 'abc'.hashCode); + }); + + test('None hashCode is 0', () { + expect(const Option.none().hashCode, 0); + }); + }); + }); + + group('ObjectOptionExtension', () { + test('non-null value.toOption() returns Some', () { + final option = 'hello'.toOption(); + expect(option, isA>()); + expect((option as Some).value, 'hello'); + }); + + test('null value.toOption() returns None', () { + const String? value = null; + final option = value.toOption(); + expect(option, isA>()); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 13d6ba7e56..85ea126a6d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3703,7 +3703,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetEditsDto" + "$ref": "#/components/schemas/AssetEditsResponseDto" } } }, @@ -3756,7 +3756,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetEditActionListDto" + "$ref": "#/components/schemas/AssetEditsCreateDto" } } }, @@ -3767,7 +3767,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetEditsDto" + "$ref": "#/components/schemas/AssetEditsResponseDto" } } }, @@ -5052,7 +5052,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/DownloadArchiveDto" } } }, @@ -16082,7 +16082,7 @@ ], "type": "string" }, - "AssetEditActionCrop": { + "AssetEditActionItemDto": { "properties": { "action": { "allOf": [ @@ -16093,7 +16093,18 @@ "description": "Type of edit action to perform" }, "parameters": { - "$ref": "#/components/schemas/CropParameters" + "anyOf": [ + { + "$ref": "#/components/schemas/CropParameters" + }, + { + "$ref": "#/components/schemas/RotateParameters" + }, + { + "$ref": "#/components/schemas/MirrorParameters" + } + ], + "description": "List of edit actions to apply (crop, rotate, or mirror)" } }, "required": [ @@ -16102,22 +16113,48 @@ ], "type": "object" }, - "AssetEditActionListDto": { + "AssetEditActionItemResponseDto": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ], + "description": "Type of edit action to perform" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "parameters": { + "anyOf": [ + { + "$ref": "#/components/schemas/CropParameters" + }, + { + "$ref": "#/components/schemas/RotateParameters" + }, + { + "$ref": "#/components/schemas/MirrorParameters" + } + ], + "description": "List of edit actions to apply (crop, rotate, or mirror)" + } + }, + "required": [ + "action", + "id", + "parameters" + ], + "type": "object" + }, + "AssetEditsCreateDto": { "properties": { "edits": { "description": "List of edit actions to apply (crop, rotate, or mirror)", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/AssetEditActionCrop" - }, - { - "$ref": "#/components/schemas/AssetEditActionRotate" - }, - { - "$ref": "#/components/schemas/AssetEditActionMirror" - } - ] + "$ref": "#/components/schemas/AssetEditActionItemDto" }, "minItems": 1, "type": "array" @@ -16128,69 +16165,18 @@ ], "type": "object" }, - "AssetEditActionMirror": { - "properties": { - "action": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetEditAction" - } - ], - "description": "Type of edit action to perform" - }, - "parameters": { - "$ref": "#/components/schemas/MirrorParameters" - } - }, - "required": [ - "action", - "parameters" - ], - "type": "object" - }, - "AssetEditActionRotate": { - "properties": { - "action": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetEditAction" - } - ], - "description": "Type of edit action to perform" - }, - "parameters": { - "$ref": "#/components/schemas/RotateParameters" - } - }, - "required": [ - "action", - "parameters" - ], - "type": "object" - }, - "AssetEditsDto": { + "AssetEditsResponseDto": { "properties": { "assetId": { - "description": "Asset ID to apply edits to", + "description": "Asset ID these edits belong to", "format": "uuid", "type": "string" }, "edits": { - "description": "List of edit actions to apply (crop, rotate, or mirror)", + "description": "List of edit actions applied to the asset", "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/AssetEditActionCrop" - }, - { - "$ref": "#/components/schemas/AssetEditActionRotate" - }, - { - "$ref": "#/components/schemas/AssetEditActionMirror" - } - ] + "$ref": "#/components/schemas/AssetEditActionItemResponseDto" }, - "minItems": 1, "type": "array" } }, @@ -17084,7 +17070,7 @@ "type": "array" }, "thumbhash": { - "description": "Thumbhash for thumbnail generation", + "description": "Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.", "nullable": true, "type": "string" }, @@ -17662,6 +17648,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": { @@ -18645,6 +18651,22 @@ "data": { "$ref": "#/components/schemas/OnThisDayDto" }, + "hideAt": { + "description": "Date when memory should be hidden", + "format": "date-time", + "type": "string", + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Stable" + } + ], + "x-immich-state": "Stable" + }, "isSaved": { "description": "Is memory saved", "type": "boolean" @@ -18659,6 +18681,22 @@ "format": "date-time", "type": "string" }, + "showAt": { + "description": "Date when memory should be shown", + "format": "date-time", + "type": "string", + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Stable" + } + ], + "x-immich-state": "Stable" + }, "type": { "allOf": [ { @@ -22576,6 +22614,48 @@ ], "type": "object" }, + "SyncAssetEditDeleteV1": { + "properties": { + "editId": { + "type": "string" + } + }, + "required": [ + "editId" + ], + "type": "object" + }, + "SyncAssetEditV1": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "assetId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "parameters": { + "type": "object" + }, + "sequence": { + "type": "integer" + } + }, + "required": [ + "action", + "assetId", + "id", + "parameters", + "sequence" + ], + "type": "object" + }, "SyncAssetExifV1": { "properties": { "assetId": { @@ -22803,6 +22883,70 @@ ], "type": "object" }, + "SyncAssetFaceV2": { + "properties": { + "assetId": { + "description": "Asset ID", + "type": "string" + }, + "boundingBoxX1": { + "type": "integer" + }, + "boundingBoxX2": { + "type": "integer" + }, + "boundingBoxY1": { + "type": "integer" + }, + "boundingBoxY2": { + "type": "integer" + }, + "deletedAt": { + "description": "Face deleted at", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Asset face ID", + "type": "string" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + }, + "isVisible": { + "description": "Is the face visible in the asset", + "type": "boolean" + }, + "personId": { + "description": "Person ID", + "nullable": true, + "type": "string" + }, + "sourceType": { + "description": "Source type", + "type": "string" + } + }, + "required": [ + "assetId", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2", + "deletedAt", + "id", + "imageHeight", + "imageWidth", + "isVisible", + "personId", + "sourceType" + ], + "type": "object" + }, "SyncAssetMetadataDeleteV1": { "properties": { "assetId": { @@ -23061,6 +23205,8 @@ "AssetV1", "AssetDeleteV1", "AssetExifV1", + "AssetEditV1", + "AssetEditDeleteV1", "AssetMetadataV1", "AssetMetadataDeleteV1", "PartnerV1", @@ -23096,6 +23242,7 @@ "PersonV1", "PersonDeleteV1", "AssetFaceV1", + "AssetFaceV2", "AssetFaceDeleteV1", "UserMetadataV1", "UserMetadataDeleteV1", @@ -23357,6 +23504,7 @@ "AlbumAssetExifsV1", "AssetsV1", "AssetExifsV1", + "AssetEditsV1", "AssetMetadataV1", "AuthUsersV1", "MemoriesV1", @@ -23369,6 +23517,7 @@ "UsersV1", "PeopleV1", "AssetFacesV1", + "AssetFacesV2", "UserMetadataV1" ], "type": "string" 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 59a25d58b3..9dae33541e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -616,7 +616,7 @@ export type AssetResponseDto = { resized?: boolean; stack?: (AssetStackResponseDto) | null; tags?: TagResponseDto[]; - /** Thumbhash for thumbnail generation */ + /** Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting. */ thumbhash: string | null; /** Asset type */ "type": AssetTypeEnum; @@ -959,38 +959,36 @@ export type CropParameters = { /** Top-Left Y coordinate of crop */ y: number; }; -export type AssetEditActionCrop = { - /** Type of edit action to perform */ - action: AssetEditAction; - parameters: CropParameters; -}; export type RotateParameters = { /** Rotation angle in degrees */ angle: number; }; -export type AssetEditActionRotate = { - /** Type of edit action to perform */ - action: AssetEditAction; - parameters: RotateParameters; -}; export type MirrorParameters = { /** Axis to mirror along */ axis: MirrorAxis; }; -export type AssetEditActionMirror = { +export type AssetEditActionItemResponseDto = { /** Type of edit action to perform */ action: AssetEditAction; - parameters: MirrorParameters; + id: string; + /** List of edit actions to apply (crop, rotate, or mirror) */ + parameters: CropParameters | RotateParameters | MirrorParameters; }; -export type AssetEditsDto = { - /** Asset ID to apply edits to */ +export type AssetEditsResponseDto = { + /** Asset ID these edits belong to */ assetId: string; - /** List of edit actions to apply (crop, rotate, or mirror) */ - edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; + /** List of edit actions applied to the asset */ + edits: AssetEditActionItemResponseDto[]; }; -export type AssetEditActionListDto = { +export type AssetEditActionItemDto = { + /** Type of edit action to perform */ + action: AssetEditAction; /** List of edit actions to apply (crop, rotate, or mirror) */ - edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; + parameters: CropParameters | RotateParameters | MirrorParameters; +}; +export type AssetEditsCreateDto = { + /** List of edit actions to apply (crop, rotate, or mirror) */ + edits: AssetEditActionItemDto[]; }; export type AssetMetadataResponseDto = { /** Metadata key */ @@ -1132,9 +1130,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 */ @@ -1404,12 +1404,16 @@ export type MemoryCreateDto = { /** Asset IDs to associate with memory */ assetIds?: string[]; data: OnThisDayDto; + /** Date when memory should be hidden */ + hideAt?: string; /** Is memory saved */ isSaved?: boolean; /** Memory date */ memoryAt: string; /** Date when memory was seen */ seenAt?: string; + /** Date when memory should be shown */ + showAt?: string; /** Memory type */ "type": MemoryType; }; @@ -2309,6 +2313,10 @@ export type SharedLinkEditDto = { /** Custom URL slug */ slug?: string | null; }; +export type AssetIdsDto = { + /** Asset IDs */ + assetIds: string[]; +}; export type AssetIdsResponseDto = { /** Asset ID */ assetId: string; @@ -2959,6 +2967,16 @@ export type SyncAssetDeleteV1 = { /** Asset ID */ assetId: string; }; +export type SyncAssetEditDeleteV1 = { + editId: string; +}; +export type SyncAssetEditV1 = { + action: AssetEditAction; + assetId: string; + id: string; + parameters: object; + sequence: number; +}; export type SyncAssetExifV1 = { /** Asset ID */ assetId: string; @@ -3031,6 +3049,26 @@ export type SyncAssetFaceV1 = { /** Source type */ sourceType: string; }; +export type SyncAssetFaceV2 = { + /** Asset ID */ + assetId: string; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + /** Face deleted at */ + deletedAt: string | null; + /** Asset face ID */ + id: string; + imageHeight: number; + imageWidth: number; + /** Is the face visible in the asset */ + isVisible: boolean; + /** Person ID */ + personId: string | null; + /** Source type */ + sourceType: string; +}; export type SyncAssetMetadataDeleteV1 = { /** Asset ID */ assetId: string; @@ -4123,7 +4161,7 @@ export function getAssetEdits({ id }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetEditsDto; + data: AssetEditsResponseDto; }>(`/assets/${encodeURIComponent(id)}/edits`, { ...opts })); @@ -4131,17 +4169,17 @@ export function getAssetEdits({ id }: { /** * Apply edits to an existing asset */ -export function editAsset({ id, assetEditActionListDto }: { +export function editAsset({ id, assetEditsCreateDto }: { id: string; - assetEditActionListDto: AssetEditActionListDto; + assetEditsCreateDto: AssetEditsCreateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetEditsDto; + data: AssetEditsResponseDto; }>(`/assets/${encodeURIComponent(id)}/edits`, oazapfts.json({ ...opts, method: "PUT", - body: assetEditActionListDto + body: assetEditsCreateDto }))); } /** @@ -4433,10 +4471,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; @@ -4447,7 +4485,7 @@ export function downloadArchive({ key, slug, assetIdsDto }: { }))}`, oazapfts.json({ ...opts, method: "POST", - body: assetIdsDto + body: downloadArchiveDto }))); } /** @@ -7202,6 +7240,8 @@ export enum SyncEntityType { AssetV1 = "AssetV1", AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", + AssetEditV1 = "AssetEditV1", + AssetEditDeleteV1 = "AssetEditDeleteV1", AssetMetadataV1 = "AssetMetadataV1", AssetMetadataDeleteV1 = "AssetMetadataDeleteV1", PartnerV1 = "PartnerV1", @@ -7237,6 +7277,7 @@ export enum SyncEntityType { PersonV1 = "PersonV1", PersonDeleteV1 = "PersonDeleteV1", AssetFaceV1 = "AssetFaceV1", + AssetFaceV2 = "AssetFaceV2", AssetFaceDeleteV1 = "AssetFaceDeleteV1", UserMetadataV1 = "UserMetadataV1", UserMetadataDeleteV1 = "UserMetadataDeleteV1", @@ -7252,6 +7293,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = "AlbumAssetExifsV1", AssetsV1 = "AssetsV1", AssetExifsV1 = "AssetExifsV1", + AssetEditsV1 = "AssetEditsV1", AssetMetadataV1 = "AssetMetadataV1", AuthUsersV1 = "AuthUsersV1", MemoriesV1 = "MemoriesV1", @@ -7264,6 +7306,7 @@ export enum SyncRequestType { UsersV1 = "UsersV1", PeopleV1 = "PeopleV1", AssetFacesV1 = "AssetFacesV1", + AssetFacesV2 = "AssetFacesV2", UserMetadataV1 = "UserMetadataV1" } export enum TranscodeHWAccel { diff --git a/package.json b/package.json index 0e4017f928..b49e12c3e9 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.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937", "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/pnpm-lock.yaml b/pnpm-lock.yaml index a435f3db6d..b7568e0436 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: @@ -42,10 +42,10 @@ importers: version: 4.0.8 devDependencies: '@eslint/js': - specifier: ^9.8.0 - version: 9.39.2 + specifier: ^10.0.0 + version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) '@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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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 @@ -78,20 +78,20 @@ importers: specifier: ^12.0.0 version: 12.1.0 eslint: - specifier: ^9.14.0 - version: 9.39.2(jiti@2.6.1) + specifier: ^10.0.0 + version: 10.0.2(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@10.0.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.1) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.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)) + specifier: ^63.0.0 + version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) globals: - specifier: ^16.0.0 - version: 16.5.0 + specifier: ^17.0.0 + version: 17.3.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -106,19 +106,19 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@10.0.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) + version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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.1.0(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.31.1)(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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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.13)(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.13)(react@18.3.1))(@types/react@19.2.13)(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.13)(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.13)(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.13)(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.13)(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.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.13)(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 @@ -196,19 +196,19 @@ importers: e2e: devDependencies: '@eslint/js': - specifier: ^9.8.0 - version: 9.39.2 + specifier: ^10.0.0 + version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) '@faker-js/faker': specifier: ^10.1.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 @@ -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,25 +233,25 @@ importers: version: 6.0.3 dotenv: specifier: ^17.2.3 - version: 17.2.4 + version: 17.3.1 eslint: - specifier: ^9.14.0 - version: 9.39.2(jiti@2.6.1) + specifier: ^10.0.0 + version: 10.0.2(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@10.0.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.1) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.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)) + specifier: ^63.0.0 + version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) exiftool-vendored: - specifier: ^34.3.0 - version: 34.3.0 + specifier: ^35.0.0 + version: 35.10.1 globals: - specifier: ^16.0.0 - version: 16.5.0 + specifier: ^17.0.0 + version: 17.3.0 luxon: specifier: ^3.4.4 version: 3.7.2 @@ -281,13 +281,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@10.0.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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -299,7 +299,7 @@ importers: version: 5.10.0 oidc-provider: specifier: ^9.0.0 - version: 9.6.0 + version: 9.6.1 tsx: specifier: ^4.20.6 version: 4.21.0 @@ -317,10 +317,10 @@ importers: 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 @@ -343,60 +343,63 @@ importers: '@extism/extism': specifier: 2.0.0-rc13 version: 2.0.0-rc13 + '@immich/sql-tools': + specifier: ^0.2.0 + version: 0.2.0 '@nestjs/bullmq': specifier: ^11.0.1 - 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.67.3) + version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.69.3) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.14(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.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) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - 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) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@nestjs/platform-socket.io': specifier: ^11.0.4 - 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) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.0.0 - 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) + version: 6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@nestjs/swagger': specifier: ^11.0.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) + version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - 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) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(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.5.0(@opentelemetry/api@1.9.0) + version: 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-prometheus': - specifier: ^0.211.0 - version: 0.211.0(@opentelemetry/api@1.9.0) + specifier: ^0.212.0 + version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-http': - specifier: ^0.211.0 - version: 0.211.0(@opentelemetry/api@1.9.0) + specifier: ^0.212.0 + version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-ioredis': - specifier: ^0.59.0 - version: 0.59.0(@opentelemetry/api@1.9.0) + specifier: ^0.60.0 + version: 0.60.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-nestjs-core': - specifier: ^0.57.0 - version: 0.57.0(@opentelemetry/api@1.9.0) + specifier: ^0.58.0 + version: 0.58.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-pg': - specifier: ^0.63.0 - version: 0.63.0(@opentelemetry/api@1.9.0) + specifier: ^0.64.0 + version: 0.64.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': specifier: ^2.0.1 - version: 2.5.0(@opentelemetry/api@1.9.0) + version: 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': specifier: ^2.0.1 - version: 2.5.0(@opentelemetry/api@1.9.0) + version: 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': - specifier: ^0.211.0 - version: 0.211.0(@opentelemetry/api@1.9.0) + specifier: ^0.212.0 + version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: ^1.34.0 version: 1.39.0 @@ -411,7 +414,7 @@ importers: 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 +429,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.67.3 + version: 5.69.3 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -449,8 +452,8 @@ importers: specifier: 4.4.0 version: 4.4.0 exiftool-vendored: - specifier: ^34.3.0 - version: 34.3.0 + specifier: ^35.0.0 + version: 35.10.1 express: specifier: ^5.1.0 version: 5.2.1 @@ -471,7 +474,7 @@ importers: version: 7.14.0 ioredis: specifier: ^5.8.2 - version: 5.9.2 + version: 5.9.3 jose: specifier: ^5.10.0 version: 5.10.0 @@ -501,22 +504,22 @@ importers: version: 2.0.2 nest-commander: specifier: ^3.16.0 - 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) + version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@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.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) + version: 5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.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) + version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^7.0.0 - 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) + version: 7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) nodemailer: specifier: ^7.0.0 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.18.0 @@ -549,7 +552,7 @@ importers: version: 1.6.3 sanitize-html: specifier: ^2.14.0 - version: 2.17.0 + version: 2.17.1 semver: specifier: ^7.6.2 version: 7.7.4 @@ -582,8 +585,8 @@ importers: version: 13.15.26 devDependencies: '@eslint/js': - specifier: ^9.8.0 - version: 9.39.2 + specifier: ^10.0.0 + version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) '@nestjs/cli': specifier: ^11.0.2 version: 11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13) @@ -592,7 +595,7 @@ importers: version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.4 - 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) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) '@swc/core': specifier: ^1.4.14 version: 1.15.11(@swc/helpers@0.5.17) @@ -639,11 +642,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.9 + version: 7.0.10 '@types/picomatch': specifier: ^4.0.0 version: 4.0.2 @@ -652,7 +655,7 @@ importers: version: 6.0.5 '@types/react': specifier: ^19.0.0 - version: 19.2.13 + version: 19.2.14 '@types/sanitize-html': specifier: ^2.13.0 version: 2.16.0 @@ -670,22 +673,22 @@ 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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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) + specifier: ^10.0.0 + version: 10.0.2(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@10.0.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.1) + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.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)) + specifier: ^63.0.0 + version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) globals: - specifier: ^16.0.0 - version: 16.5.0 + specifier: ^17.0.0 + version: 17.3.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -703,7 +706,7 @@ importers: version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) sql-formatter: specifier: ^15.0.0 - version: 15.7.0 + version: 15.7.2 supertest: specifier: ^7.1.0 version: 7.2.2 @@ -712,22 +715,22 @@ importers: version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) testcontainers: specifier: ^11.0.0 - version: 11.11.0 + version: 11.12.0 typescript: specifier: ^5.9.2 version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 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.1.0(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.31.1)(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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -738,14 +741,14 @@ importers: 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.1 - version: 0.62.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0) '@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 @@ -775,13 +778,13 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.50.0) + version: 0.3.9(svelte@5.53.0) 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,7 +796,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.5.0 + version: 20.6.3 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -808,7 +811,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.17.0 + version: 5.18.0 pmtiles: specifier: ^4.3.0 version: 4.4.0 @@ -826,16 +829,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.50.0) + version: 4.0.1(svelte@5.53.0) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.50.0) + version: 3.11.0(svelte@5.53.0) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.50.0) + version: 1.2.6(svelte@5.53.0) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.50.0) + version: 0.12.0(svelte@5.53.0) tabbable: specifier: ^6.2.0 version: 6.4.0 @@ -850,38 +853,38 @@ importers: version: 1.6.32 devDependencies: '@eslint/js': - specifier: ^9.36.0 - version: 9.39.2 + specifier: ^10.0.0 + version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) '@faker-js/faker': specifier: ^10.0.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) + version: 0.2.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@socket.io/component-emitter': specifier: ^3.1.0 version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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))) + version: 3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 - version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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)) + version: 0.10.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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)) + version: 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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)) + version: 6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.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)) + version: 4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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.5.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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,31 +908,31 @@ 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.2.3)(happy-dom@20.5.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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 - version: 17.2.4 + version: 17.3.1 eslint: - specifier: ^9.36.0 - version: 9.39.2(jiti@2.6.1) + specifier: ^10.0.0 + version: 10.0.2(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-compat: specifier: ^6.0.2 - version: 6.1.0(eslint@9.39.2(jiti@2.6.1)) + version: 6.2.0(eslint@10.0.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.50.0) + version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.0) eslint-plugin-unicorn: - specifier: ^62.0.0 - version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) + specifier: ^63.0.0 + version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) factory.ts: specifier: ^1.4.1 version: 1.4.2 globals: - specifier: ^16.0.0 - version: 16.5.0 + specifier: ^17.0.0 + version: 17.3.0 prettier: specifier: ^3.7.4 version: 3.8.1 @@ -941,34 +944,34 @@ importers: version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.1(prettier@3.8.1)(svelte@5.50.0) + version: 3.5.0(prettier@3.8.1)(svelte@5.53.0) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.50.0 - version: 5.50.0 + specifier: 5.53.0 + version: 5.53.0 svelte-check: specifier: ^4.1.5 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3) + version: 4.4.1(picomatch@4.0.3)(svelte@5.53.0)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.50.0) + version: 1.4.1(svelte@5.53.0) tailwindcss: specifier: ^4.1.7 - version: 4.1.18 + version: 4.2.0 typescript: specifier: ^5.8.3 version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.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) + version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.2.3)(happy-dom@20.5.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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -2715,33 +2718,34 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.2': + resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.2': + resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.1.0': + resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.2': + resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.6.0': + resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@extism/extism@2.0.0-rc13': resolution: {integrity: sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==} @@ -3013,13 +3017,16 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} + '@immich/sql-tools@0.2.0': + resolution: {integrity: sha512-AH0GRIUYrckNKuid5uO33vgRbGaznhRtArdQ91K310A1oUFjaoNzOaZyZhXwEmft3WYeC1bx4fdgUeois2QH5A==} + '@immich/svelte-markdown-preprocess@0.2.1': resolution: {integrity: sha512-mbr/g75lO8Zh+ELCuYrZP0XB4gf2UbK8rJcGYMYxFJJzMMunV+sm9FqtV1dbwW2dpXzCZGz1XPCEZ6oo526TbA==} peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.62.1': - resolution: {integrity: sha512-+rZAjw24pAIJ1hmCtYF16BECh+7M09UudTPc28z6U2J3CZzSOs0+Nsz5fTs8SE5wyC45QKdPWJCS//xFMrrRUg==} + '@immich/ui@0.64.0': + resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==} peerDependencies: svelte: ^5.0.0 @@ -3278,8 +3285,8 @@ packages: resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} engines: {node: '>= 14.0.0'} - '@koa/router@15.1.0': - resolution: {integrity: sha512-0zCmuapmgBHrfVSFjBfCdgnkBnXwRGcG5qHnxVs8ZoTNEJiwSSspgJ5+2NugiqLJS/S0d96KMeNntLqTNWaioQ==} + '@koa/router@15.3.0': + resolution: {integrity: sha512-s87hWJjFYky2Z97u8jzah73sSHp4IZivD/2PZCuspHRvcKU69OPLoBIbKigVlBmS50yFTh9GHFfr1hDag4+wXw==} engines: {node: '>= 20'} peerDependencies: koa: ^2.0.0 || ^3.0.0 @@ -3287,6 +3294,9 @@ packages: '@koddsson/eslint-plugin-tscompat@0.2.0': resolution: {integrity: sha512-Oqd4kWSX0LiO9wWHjcmDfXZNC7TotFV/tLRhwCFU3XUeb//KYvJ75c9OmeSJ+vBv5lkCeB+xYsqyNrBc5j18XA==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -3310,48 +3320,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==} @@ -3461,8 +3449,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.13': - resolution: {integrity: sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==} + '@nestjs/common@11.1.14': + resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -3474,8 +3462,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.13': - resolution: {integrity: sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==} + '@nestjs/core@11.1.14': + resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -3505,14 +3493,14 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.13': - resolution: {integrity: sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==} + '@nestjs/platform-express@11.1.14': + resolution: {integrity: sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-socket.io@11.1.13': - resolution: {integrity: sha512-04Rh16IopZzHRXt0ZjFASqt9oNFV/0m0NsYe4kVOSaTEoef3cH7cTFpNpHsfNHcc4QpYL963XE8SvIRcZs5L8A==} + '@nestjs/platform-socket.io@11.1.14': + resolution: {integrity: sha512-LLSIWkYz4FcvUhfepillYQboo9qbjq1YtQj8XC3zyex+EaqNXvxhZntx/1uJhAjc655pJts9HfZwWXei8jrRGw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -3546,8 +3534,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.13': - resolution: {integrity: sha512-bOWP8nLEZAOEEX8jAZGBCc1yU0+nv4g2ipc+QEzkVUe3eEEUKHKaeGafJ3GtDuGavlZKfkXEqflZuICdavu5dQ==} + '@nestjs/testing@11.1.14': + resolution: {integrity: sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3559,8 +3547,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.1.13': - resolution: {integrity: sha512-8r8EadqBkrTYtH2uog42HfIb5fcP5a3iXymH/ityd9bO/gDson5Q1qbtCQRjuU++6NY12YYteKRu4eP/iErbLw==} + '@nestjs/websockets@11.1.14': + resolution: {integrity: sha512-fVP6RmmrmtLIitTXN9er7BUOIjjxcdIewN/zUtBlwgfng+qKBTxpNFOs3AXXbCu8bQr2xjzhjrBTfqri0Ske7w==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3600,97 +3588,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.211.0': - resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} 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.211.0': - resolution: {integrity: sha512-PNsCkzsYQKyv8wiUIsH+loC4RYyblOaDnVASBtKS22hK55ToWs2UP6IsrcfSWWn54wWTvVe2gnfwz67Pvrxf2Q==} + '@opentelemetry/configuration@0.212.0': + resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks@2.5.0': - resolution: {integrity: sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw==} + '@opentelemetry/context-async-hooks@2.5.1': + resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.5.0': - resolution: {integrity: sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==} + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.211.0': - resolution: {integrity: sha512-UhOoWENNqyaAMP/dL1YXLkXt6ZBtovkDDs1p4rxto9YwJX1+wMjwg+Obfyg2kwpcMoaiIFT3KQIcLNW8nNGNfQ==} + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-http@0.211.0': - resolution: {integrity: sha512-c118Awf1kZirHkqxdcF+rF5qqWwNjJh+BB1CmQvN9AQHC/DUIldy6dIkJn3EKlQnQ3HmuNRKc/nHHt5IusN7mA==} + '@opentelemetry/exporter-logs-otlp-http@0.212.0': + resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-proto@0.211.0': - resolution: {integrity: sha512-kMvfKMtY5vJDXeLnwhrZMEwhZ2PN8sROXmzacFU/Fnl4Z79CMrOaL7OE+5X3SObRYlDUa7zVqaXp9ZetYCxfDQ==} + '@opentelemetry/exporter-logs-otlp-proto@0.212.0': + resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-grpc@0.211.0': - resolution: {integrity: sha512-D/U3G8L4PzZp8ot5hX9wpgbTymgtLZCiwR7heMe4LsbGV4OdctS1nfyvaQHLT6CiGZ6FjKc1Vk9s6kbo9SWLXQ==} + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-http@0.211.0': - resolution: {integrity: sha512-lfHXElPAoDSPpPO59DJdN5FLUnwi1wxluLTWQDayqrSPfWRnluzxRhD+g7rF8wbj1qCz0sdqABl//ug1IZyWvA==} + '@opentelemetry/exporter-metrics-otlp-http@0.212.0': + resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-proto@0.211.0': - resolution: {integrity: sha512-61iNbffEpyZv/abHaz3BQM3zUtA2kVIDBM+0dS9RK68ML0QFLRGYa50xVMn2PYMToyfszEPEgFC3ypGae2z8FA==} + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': + resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.211.0': - resolution: {integrity: sha512-cD0WleEL3TPqJbvxwz5MVdVJ82H8jl8mvMad4bNU24cB5SH2mRW5aMLDTuV4614ll46R//R3RMmci26mc2L99g==} + '@opentelemetry/exporter-prometheus@0.212.0': + resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.211.0': - resolution: {integrity: sha512-eFwx4Gvu6LaEiE1rOd4ypgAiWEdZu7Qzm2QNN2nJqPW1XDeAVH1eNwVcVQl+QK9HR/JCDZ78PZgD7xD/DBDqbw==} + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': + resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-http@0.211.0': - resolution: {integrity: sha512-F1Rv3JeMkgS//xdVjbQMrI3+26e5SXC7vXA6trx8SWEA0OUhw4JHB+qeHtH0fJn46eFItrYbL5m8j4qi9Sfaxw==} + '@opentelemetry/exporter-trace-otlp-http@0.212.0': + resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.211.0': - resolution: {integrity: sha512-DkjXwbPiqpcPlycUojzG2RmR0/SIK8Gi9qWO9znNvSqgzrnAIE9x2n6yPfpZ+kWHZGafvsvA1lVXucTyyQa5Kg==} + '@opentelemetry/exporter-trace-otlp-proto@0.212.0': + resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@2.5.0': - resolution: {integrity: sha512-bk9VJgFgUAzkZzU8ZyXBSWiUGLOM3mZEgKJ1+jsZclhRnAoDNf+YBdq+G9R3cP0+TKjjWad+vVrY/bE/vRR9lA==} + '@opentelemetry/exporter-zipkin@2.5.1': + resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 @@ -3701,62 +3689,62 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-http@0.211.0': - resolution: {integrity: sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==} + '@opentelemetry/instrumentation-http@0.212.0': + resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-ioredis@0.59.0': - resolution: {integrity: sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==} + '@opentelemetry/instrumentation-ioredis@0.60.0': + resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-nestjs-core@0.57.0': - resolution: {integrity: sha512-mzTjjethjuk70o/vWUeV12QwMG9EAFJpkn13/q8zi++sNosf2hoGXTplIdbs81U8S3PJ4GxHKsBjM0bj1CGZ0g==} + '@opentelemetry/instrumentation-nestjs-core@0.58.0': + resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pg@0.63.0': - resolution: {integrity: sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==} + '@opentelemetry/instrumentation-pg@0.64.0': + resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.211.0': - resolution: {integrity: sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==} + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.211.0': - resolution: {integrity: sha512-bp1+63V8WPV+bRI9EQG6E9YID1LIHYSZVbp7f+44g9tRzCq+rtw/o4fpL5PC31adcUsFiz/oN0MdLISSrZDdrg==} + '@opentelemetry/otlp-exporter-base@0.212.0': + resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.211.0': - resolution: {integrity: sha512-mR5X+N4SuphJeb7/K7y0JNMC8N1mB6gEtjyTLv+TSAhl0ZxNQzpSKP8S5Opk90fhAqVYD4R0SQSAirEBlH1KSA==} + '@opentelemetry/otlp-grpc-exporter-base@0.212.0': + resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.211.0': - resolution: {integrity: sha512-julhCJ9dXwkOg9svuuYqqjXLhVaUgyUvO2hWbTxwjvLXX2rG3VtAaB0SzxMnGTuoCZizBT7Xqqm2V7+ggrfCXA==} + '@opentelemetry/otlp-transformer@0.212.0': + resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagator-b3@2.5.0': - resolution: {integrity: sha512-g10m4KD73RjHrSvUge+sUxUl8m4VlgnGc6OKvo68a4uMfaLjdFU+AULfvMQE/APq38k92oGUxEzBsAZ8RN/YHg==} + '@opentelemetry/propagator-b3@2.5.1': + resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.5.0': - resolution: {integrity: sha512-t70ErZCncAR/zz5AcGkL0TF25mJiK1FfDPEQCgreyAHZ+mRJ/bNUiCnImIBDlP3mSDXy6N09DbUEKq0ktW98Hg==} + '@opentelemetry/propagator-jaeger@2.5.1': + resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -3765,38 +3753,38 @@ packages: resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} - '@opentelemetry/resources@2.5.0': - resolution: {integrity: sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==} + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.211.0': - resolution: {integrity: sha512-O5nPwzgg2JHzo59kpQTPUOTzFi0Nv5LxryG27QoXBciX3zWM3z83g+SNOHhiQVYRWFSxoWn1JM2TGD5iNjOwdA==} + '@opentelemetry/sdk-logs@0.212.0': + resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.5.0': - resolution: {integrity: sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==} + '@opentelemetry/sdk-metrics@2.5.1': + resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.211.0': - resolution: {integrity: sha512-+s1eGjoqmPCMptNxcJJD4IxbWJKNLOQFNKhpwkzi2gLkEbCj6LzSHJNhPcLeBrBlBLtlSpibM+FuS7fjZ8SSFQ==} + '@opentelemetry/sdk-node@0.212.0': + resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.5.0': - resolution: {integrity: sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==} + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.5.0': - resolution: {integrity: sha512-O6N/ejzburFm2C84aKNrwJVPpt6HSTSq8T0ZUMq3xT2XmqT4cwxUItcL5UWGThYuq8RTcbH8u1sfj6dmRci0Ow==} + '@opentelemetry/sdk-trace-node@2.5.1': + resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -3932,8 +3920,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.1 - '@photostructure/tz-lookup@11.3.0': - resolution: {integrity: sha512-rYGy7ETBHTnXrwbzm47e3LJPKJmzpY7zXnbZhdosNU0lTGWVqzxptSjK4qZkJ1G+Kwy4F6XStNR9ZqMsXAoASQ==} + '@photostructure/tz-lookup@11.4.0': + resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -4332,15 +4320,15 @@ packages: peerDependencies: '@sveltejs/kit': ^2.0.0 - '@sveltejs/enhanced-img@0.10.0': - resolution: {integrity: sha512-+nSrtNfs2dgKQ6RHMoKO6chl1QoO8JsuwKHkj9LkA2fUwzDYkeYoWvJzddOJIbgmowMdhi9cLo6tckSU+Kk7DQ==} + '@sveltejs/enhanced-img@0.10.2': + resolution: {integrity: sha512-HcIX7KFaLe+3ZD+GcMIlOGKODO8zb8p6I5tY8aoM9tz4GwueGyn9gILyTWZHqXYgg7PXto++ELB/q68wC9j4qw==} peerDependencies: '@sveltejs/vite-plugin-svelte': ^6.0.0 svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.50.2': - resolution: {integrity: sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA==} + '@sveltejs/kit@2.52.2': + resolution: {integrity: sha512-1in76dftrofUt138rVLvYuwiQLkg9K3cG8agXEE6ksf7gCGs8oIr3+pFrVtbRmY9JvW+psW5fvLM/IwVybOLBA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4534,69 +4522,69 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@tailwindcss/node@4.2.0': + resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.2.0': + resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.2.0': + resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.2.0': + resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.2.0': + resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.2.0': + resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + '@tailwindcss/oxide-wasm32-wasi@4.2.0': + resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4607,24 +4595,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.2.0': + resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + engines: {node: '>= 20'} - '@tailwindcss/vite@4.1.18': - resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + '@tailwindcss/vite@4.2.0': + resolution: {integrity: sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 @@ -4668,10 +4656,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'} @@ -4855,8 +4839,8 @@ packages: '@types/docker-modem@3.0.6': resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} - '@types/dockerode@3.3.47': - resolution: {integrity: sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==} + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} '@types/dom-to-image@2.6.7': resolution: {integrity: sha512-me5VbCv+fcXozblWwG13krNBvuEOm6kA5xoa4RrjDJCNFOZSWR3/QLtOXimBHk1Fisq69Gx3JtOoXtg1N1tijg==} @@ -4867,6 +4851,9 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -5008,11 +4995,11 @@ packages: '@types/node@24.10.13': resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - '@types/node@25.2.3': - resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/node@25.3.0': + resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} - '@types/nodemailer@7.0.9': - resolution: {integrity: sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==} + '@types/nodemailer@7.0.10': + resolution: {integrity: sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==} '@types/oidc-provider@9.5.0': resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} @@ -5056,8 +5043,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@19.2.13': - resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@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==} @@ -5140,63 +5127,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -5303,10 +5290,6 @@ packages: 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==} @@ -5326,9 +5309,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: @@ -5349,8 +5329,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 @@ -5405,9 +5385,15 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + 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: @@ -5600,6 +5586,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -5608,8 +5598,8 @@ packages: bare-abort-controller: optional: true - bare-fs@4.5.2: - resolution: {integrity: sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==} + bare-fs@4.5.4: + resolution: {integrity: sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==} engines: {bare: '>=1.16.0'} peerDependencies: bare-buffer: '*' @@ -5624,8 +5614,8 @@ packages: bare-path@3.0.0: resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - bare-stream@2.7.0: - resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + bare-stream@2.8.0: + resolution: {integrity: sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==} peerDependencies: bare-buffer: '*' bare-events: '*' @@ -5649,8 +5639,8 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - batch-cluster@16.0.0: - resolution: {integrity: sha512-+T7Ho09ikx/kP4P8M+GEnpuePzRQa4gTUhtPIu6ApFC8+0GY0sri1y1PuB+yfXlQWl5DkHC/e58z3U6g0qCz/A==} + batch-cluster@17.3.1: + resolution: {integrity: sha512-/aWEgZKXgvEseV3WEIRyjDoFka9FTrpt5+FYCxn+giUgveGBKxWjz3cl26V3aD+1kvOBP3nmANZZfcXDmKzcAA==} engines: {node: '>=20'} batch@0.6.1: @@ -5673,8 +5663,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 @@ -5711,6 +5701,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.3: + resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -5744,8 +5738,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.67.3: - resolution: {integrity: sha512-eeQobOJn8M0Rj8tcZCVFLrimZgJQallJH1JpclOoyut2nDNkDwTEPMVcZzLeSR2fGeIVbfJTjU96F563Qkge5A==} + bullmq@5.69.3: + resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -5830,8 +5824,8 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001774: + resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} canvas@2.11.2: resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} @@ -5927,8 +5921,8 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} @@ -6342,9 +6336,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==} @@ -6381,16 +6372,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'} @@ -6558,10 +6539,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'} @@ -6719,8 +6696,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==} @@ -6759,8 +6736,8 @@ packages: resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} engines: {node: '>=6'} - docker-compose@1.3.0: - resolution: {integrity: sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g==} + docker-compose@1.3.1: + resolution: {integrity: sha512-rF0wH69G3CCcmkN9J1RVMQBaKe8o77LT/3XmqcLIltWWVxcWAzp2TnO7wS3n/umZHN3/EVrlT3exSBMal+Ou1w==} engines: {node: '>= 6.0.0'} docker-modem@5.0.6: @@ -6800,11 +6777,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'} @@ -6829,8 +6801,8 @@ packages: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} - dotenv@17.2.4: - resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -6840,9 +6812,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==} @@ -6913,6 +6882,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -7003,22 +6976,17 @@ 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.1.0: - resolution: {integrity: sha512-xiwHz7mj6+Zj7NWOO/uaWdrQ6zP0zL5CPyKVCNlB4JaoUFeYPYwejf5toqyHGlXzhuPUdCpg31uBRiWqcgiS0A==} + eslint-plugin-compat@6.2.0: + resolution: {integrity: sha512-Ihz4zAeHKzyksDDUTObrYQxaqnV/pFlAiZoWkMuWM9XGf4O191ReQFYv516zcs9QVJ2vX+MMpqr1yEfTkXVETQ==} engines: {node: '>=18.x'} peerDependencies: - eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 eslint-plugin-prettier@5.5.5: resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} @@ -7034,18 +7002,18 @@ 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: optional: true - eslint-plugin-unicorn@62.0.0: - resolution: {integrity: sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==} + eslint-plugin-unicorn@63.0.0: + resolution: {integrity: sha512-Iqecl9118uQEXYh7adylgEmGfkn5es3/mlQTLLkd4pXkIk9CTGrAbeUux+YljSa2ohXCBmQQ0+Ej1kZaFgcfkA==} engines: {node: ^20.10.0 || >=21.0.0} peerDependencies: eslint: '>=9.38.0' @@ -7058,6 +7026,10 @@ packages: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.1: + resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -7066,9 +7038,13 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.2: + resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -7087,13 +7063,17 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.1.1: + resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrap@2.2.3: @@ -7146,8 +7126,8 @@ packages: resolution: {integrity: sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==} engines: {node: '>=6.0.0'} - eta@4.5.0: - resolution: {integrity: sha512-qifAYjuW5AM1eEEIsFnOwB+TGqu6ynU3OKj9WbUTOtUBHFPZqL03XUW34kbp3zm19Ald+U8dEyRXaVsUck+Y1g==} + eta@4.5.1: + resolution: {integrity: sha512-EaNCGm+8XEIU7YNcc+THptWAO5NfKBHHARxt+wxZljj9bTr/+arRoOm9/MpGt4n6xn9fLnPFRSoLD0WFYGFUxQ==} engines: {node: '>=20'} etag@1.8.1: @@ -7183,17 +7163,17 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.45.0: - resolution: {integrity: sha512-xa+gEnZ2Q9BAzaDr35xgADql+T6L92RqK0GjzOjzDuObwhr+sBr5RdySvZ3osHac9GJypxvk4cewNnj4OnPL3Q==} + exiftool-vendored.exe@13.51.0: + resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==} os: [win32] - exiftool-vendored.pl@13.45.0: - resolution: {integrity: sha512-uA58bMcXqdSQAqsZbHa/SMU6XKXsmoMcJSlKJjsCmLlQKEThncuAlpg8wGVNhULNXxYmRXXnYQ1756UYQY9VIA==} + exiftool-vendored.pl@13.51.0: + resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==} os: ['!win32'] hasBin: true - exiftool-vendored@34.3.0: - resolution: {integrity: sha512-CpNH1FAhIQG5AlKndlTf05mNbuFxINyzG9629ZI/CKwr+39zWo8swxpnXc3GUfUvUfxkCCxumDPy2QVmi3XJkQ==} + exiftool-vendored@35.10.1: + resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} engines: {node: '>=20.0.0'} expect-type@1.3.0: @@ -7224,9 +7204,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==} @@ -7470,9 +7450,6 @@ packages: resolution: {integrity: sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==} hasBin: true - geojson-vt@3.2.1: - resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} - geojson@0.5.0: resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==} engines: {node: '>= 0.10'} @@ -7557,10 +7534,6 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - globals@15.15.0: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} @@ -7569,6 +7542,10 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} + globals@17.3.0: + resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} + engines: {node: '>=18'} + globalyzer@0.1.0: resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} @@ -7601,9 +7578,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'} @@ -7619,8 +7593,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.5.0: - resolution: {integrity: sha512-VQe+Q5CYiGOgcCERXhcfNsbnrN92FDEKciMH/x6LppU9dd0j4aTjCTlqONFOIMcAm/5JxS3+utowbXV1OoFr+g==} + happy-dom@20.6.3: + resolution: {integrity: sha512-QAMY7d228dHs8gb9NG4SJ3OxQo4r+NGN8pOXGZ3SGfQf/XYuuYubrtZ25QVY2WoUQdskhRXSXb4R4mcRk+hV1w==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -7721,10 +7695,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'} @@ -7796,10 +7766,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'} @@ -7892,8 +7858,8 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-in-the-middle@2.0.0: - resolution: {integrity: sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A==} + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} @@ -7959,6 +7925,10 @@ packages: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} + ioredis@5.9.3: + resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} + engines: {node: '>=12.22.0'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -8238,15 +8208,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'} @@ -8329,9 +8290,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==} @@ -8377,6 +8335,10 @@ packages: postgres: optional: true + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + kysely@0.28.2: resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==} engines: {node: '>=18.0.0'} @@ -8416,78 +8378,78 @@ packages: libphonenumber-js@1.12.31: resolution: {integrity: sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} 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==} + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} lilconfig@2.1.0: @@ -8571,9 +8533,6 @@ packages: lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -8663,12 +8622,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.17.0: - resolution: {integrity: sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==} + 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: @@ -8694,8 +8649,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 @@ -9004,6 +8959,10 @@ packages: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -9011,8 +8970,8 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.6: + resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -9074,6 +9033,11 @@ packages: engines: {node: '>=10'} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -9132,8 +9096,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nan@2.24.0: - resolution: {integrity: sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==} + nan@2.25.0: + resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -9316,8 +9280,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==} @@ -9348,8 +9312,8 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - oidc-provider@9.6.0: - resolution: {integrity: sha512-CCRUYPOumEy/DT+L86H40WgXjXfDHlsJYZdyd4ZKGFxJh/kAd7DxMX3dwpbX0g+WjB+NWU+kla1b/yZmHNcR0Q==} + oidc-provider@9.6.1: + resolution: {integrity: sha512-8AtFXE4gEV6MLd8Re78VhqGNjBm/SUw0fUxrP2XwQc+5DZKw6GyuTuy2M4jkidpH3jRrhtkkqQpXlxD1Awi6tg==} on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} @@ -9382,8 +9346,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==} @@ -10147,9 +10111,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==} @@ -10177,8 +10138,8 @@ packages: peerDependencies: prettier: ^3.0.0 - prettier-plugin-svelte@3.4.1: - resolution: {integrity: sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==} + prettier-plugin-svelte@3.5.0: + resolution: {integrity: sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==} peerDependencies: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 @@ -10233,9 +10194,9 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - properties-reader@2.3.0: - resolution: {integrity: sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==} - engines: {node: '>=14'} + properties-reader@3.0.1: + resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} + engines: {node: '>=18'} property-information@5.6.0: resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} @@ -10261,9 +10222,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==} @@ -10287,9 +10245,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==} @@ -10301,9 +10256,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==} @@ -10696,8 +10648,8 @@ packages: sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} - sanitize-html@2.17.0: - resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==} + sanitize-html@2.17.1: + resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==} sass@1.97.1: resolution: {integrity: sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==} @@ -10753,11 +10705,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -10868,8 +10815,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: @@ -10982,8 +10929,8 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sql-formatter@15.7.0: - resolution: {integrity: sha512-o2yiy7fYXK1HvzA8P6wwj8QSuwG3e/XcpWht/jIxkQX99c0SVPw0OXdLSV9fHASPiYB09HLA0uq8hokGydi/QA==} + sql-formatter@15.7.2: + resolution: {integrity: sha512-b0BGoM81KFRVSpZFwPpIPU5gng4YD8DI/taLD96NXCFRf5af3FzSE4aSwjKmxcyTmf/MfPu91j75883nRrWDBw==} hasBin: true srcset@4.0.0: @@ -11127,9 +11074,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==} @@ -11154,8 +11098,8 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.3.6: - resolution: {integrity: sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q==} + svelte-check@4.4.1: + resolution: {integrity: sha512-y1bBT0CRCMMfdjyqX1e5zCygLgEEr4KJV1qP6GSUReHl90bmcQaAWjZygHPfQ8K63f1eR8IuivuZMwmCg3zT2Q==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: @@ -11227,8 +11171,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.50.0: - resolution: {integrity: sha512-FR9kTLmX5i0oyeQ5j/+w8DuagIkQ7MWMuPpPVioW2zx9Dw77q+1ufLzF1IqNtcTXPRnIIio4PlasliVn43OnbQ==} + svelte@5.53.0: + resolution: {integrity: sha512-7dhHkSamGS2vtoBmIW2hRab+gl5Z60alEHZB4910ePqqJNxAWnDAxsofVmlZ2tREmWyHNE+A1nCKwICAquoD2A==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11302,8 +11246,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.2.0: + resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -11330,6 +11274,10 @@ packages: 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 + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} @@ -11356,8 +11304,8 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} - testcontainers@11.11.0: - resolution: {integrity: sha512-nKTJn3n/gkyGg/3SVkOwX+isPOGSHlfI+CWMobSmvQrsj7YW01aWvl2pYIfV4LMd+C8or783yYrzKSK2JlP+Qw==} + testcontainers@11.12.0: + resolution: {integrity: sha512-VWtH+UQejVYYvb53ohEZRbx2naxyDvwO9lQ6A0VgmVE2Oh8r9EF09I+BfmrXpd9N9ntpzhao9di2yNwibSz5KA==} text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} @@ -11429,9 +11377,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==} @@ -11473,10 +11418,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'} @@ -11484,10 +11425,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'} @@ -11592,11 +11529,11 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + typescript-eslint@8.56.0: + resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' typescript@5.9.3: @@ -11637,8 +11574,11 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.18.0: - resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} unicode-canonical-property-names-ecmascript@2.0.1: @@ -11712,10 +11652,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'} @@ -11759,9 +11695,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'} @@ -11839,8 +11772,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-imagetools@9.0.2: - resolution: {integrity: sha512-FV5DXw4swU81t+g8JOLT+T7gKuBOXuVsZ0WGhi7y0R182+GfBYkcf6V9/T0Nweu/vn1X0DA2p5ePMnaGZlRl1A==} + vite-imagetools@9.0.3: + resolution: {integrity: sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==} engines: {node: '>=20.0.0'} vite-node@3.2.4: @@ -11848,8 +11781,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-tsconfig-paths@6.1.0: - resolution: {integrity: sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg==} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' @@ -11955,16 +11888,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'} @@ -12062,11 +11988,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'} @@ -12080,10 +12001,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'} @@ -12196,10 +12113,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'} @@ -13636,26 +13549,26 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@docsearch/core@4.3.1(@types/react@19.2.13)(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.13 + '@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.13)(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.13)(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.13 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) search-insights: 2.17.3 @@ -13729,7 +13642,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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) @@ -13738,7 +13651,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.13)(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 @@ -13844,7 +13757,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.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 18.3.1 @@ -13858,13 +13771,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.13)(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.13)(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.13)(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.13)(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.13)(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) @@ -13899,13 +13812,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13)(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.13)(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) @@ -13939,9 +13852,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13)(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) @@ -13969,9 +13882,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.13)(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.13)(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) @@ -13996,9 +13909,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13)(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 @@ -14024,9 +13937,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13)(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 @@ -14050,9 +13963,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13)(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 @@ -14077,9 +13990,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.13)(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.13)(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 @@ -14103,9 +14016,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13)(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) @@ -14134,9 +14047,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13)(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) @@ -14164,22 +14077,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.13)(react@18.3.1))(@types/react@19.2.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(react@18.3.1))(@types/react@19.2.13)(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) @@ -14206,25 +14119,25 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@18.3.1)': dependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 - '@docusaurus/theme-classic@3.9.2(@types/react@19.2.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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.13)(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 @@ -14256,15 +14169,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.13)(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.13)(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.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -14280,11 +14193,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.13)(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.13)(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.13)(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.13)(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 @@ -14310,13 +14223,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.13)(react@18.3.1))(@types/react@19.2.13)(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.13)(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.13)(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.13)(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.13)(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) @@ -14363,7 +14276,7 @@ snapshots: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 '@types/mdast': 4.0.4 - '@types/react': 19.2.13 + '@types/react': 19.2.14 commander: 5.1.0 joi: 17.13.3 react: 18.3.1 @@ -14673,50 +14586,38 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2(jiti@2.6.1))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.23.2': dependencies: - '@eslint/object-schema': 2.1.7 + '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 10.2.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': + '@eslint/config-helpers@0.5.2': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.0 - '@eslint/core@0.17.0': + '@eslint/core@1.1.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/js@10.0.1(eslint@10.0.2(jiti@2.6.1))': + optionalDependencies: + eslint: 10.0.2(jiti@2.6.1) + + '@eslint/object-schema@3.0.2': {} + + '@eslint/plugin-kit@0.6.0': dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.2': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.0 levn: 0.4.1 '@extism/extism@2.0.0-rc13': {} @@ -14806,10 +14707,10 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.1.0 - '@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)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.23 '@grpc/grpc-js@1.14.3': @@ -14954,26 +14855,33 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.0)': + '@immich/sql-tools@0.2.0': + dependencies: + kysely: 0.28.11 + kysely-postgres-js: 3.0.0(kysely@0.28.11)(postgres@3.4.8) + pg-connection-string: 2.11.0 + postgres: 3.4.8 + + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.0)': dependencies: front-matter: 4.0.2 - marked: 17.0.1 + marked: 17.0.3 node-emoji: 2.2.0 - svelte: 5.50.0 + svelte: 5.53.0 - '@immich/ui@0.62.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.0) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.0) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0) luxon: 3.7.2 - simple-icons: 16.4.0 - svelte: 5.50.0 + simple-icons: 16.9.0 + svelte: 5.53.0 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 - tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) - tailwindcss: 4.1.18 + tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.0) + tailwindcss: 4.2.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -15233,7 +15141,7 @@ snapshots: dependencies: vary: 1.1.2 - '@koa/router@15.1.0(koa@3.1.1)': + '@koa/router@15.3.0(koa@3.1.1)': dependencies: debug: 4.4.3 http-errors: 2.0.1 @@ -15243,17 +15151,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) browserslist: 4.28.1 transitivePeerDependencies: - eslint - supports-color - typescript + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@leichtgewicht/ip-codec@2.0.5': {} '@lezer/common@1.5.0': {} @@ -15279,17 +15193,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: @@ -15322,22 +15228,12 @@ snapshots: - 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 @@ -15390,7 +15286,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 @@ -15399,7 +15295,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 @@ -15414,10 +15310,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.13)(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.13 + '@types/react': 19.2.14 react: 18.3.1 '@mermaid-js/parser@0.6.3': @@ -15446,18 +15342,18 @@ snapshots: '@namnode/store@0.1.0': {} - '@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/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@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.67.3)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.69.3)': dependencies: - '@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.67.3 + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.69.3 tslib: 2.8.1 '@nestjs/cli@11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13)': @@ -15488,7 +15384,7 @@ snapshots: - uglify-js - webpack-cli - '@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/common@11.1.14(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 @@ -15503,9 +15399,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@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/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@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/common': 11.1.14(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 @@ -15515,21 +15411,21 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@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/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(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)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(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.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(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.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.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.0.2 @@ -15538,10 +15434,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@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)': + '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2)': dependencies: - '@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) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.3 tslib: 2.8.1 @@ -15550,10 +15446,10 @@ snapshots: - supports-color - utf-8-validate - '@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)': + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(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)': @@ -15567,12 +15463,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@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)': + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(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.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) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(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.23 path-to-regexp: 8.3.0 @@ -15582,25 +15478,25 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.3 - '@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)': + '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@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/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@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/websockets@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(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.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/platform-socket.io': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) '@noble/hashes@1.8.0': {} @@ -15634,132 +15530,133 @@ snapshots: dependencies: consola: 3.4.2 - '@oazapfts/runtime@1.1.0': {} + '@oazapfts/runtime@1.2.0': {} - '@opentelemetry/api-logs@0.211.0': + '@opentelemetry/api-logs@0.212.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api@1.9.0': {} - '@opentelemetry/configuration@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) yaml: 2.8.2 - '@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@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-trace-base': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/host-metrics@0.36.2(@opentelemetry/api@1.9.0)': @@ -15767,38 +15664,38 @@ snapshots: '@opentelemetry/api': 1.9.0 systeminformation: 5.23.8 - '@opentelemetry/instrumentation-http@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-ioredis@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common': 0.38.2 '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-nestjs-core@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pg@0.63.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.211.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.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 @@ -15806,121 +15703,121 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.211.0 - import-in-the-middle: 2.0.0 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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) + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) protobufjs: 8.0.0 - '@opentelemetry/propagator-b3@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common@0.38.2': {} - '@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/sdk-logs@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@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/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.211.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.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/api-logs': 0.212.0 + '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: - supports-color - '@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@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/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/sdk-trace-node@2.5.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@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/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.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.5.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@paralleldrive/cuid2@2.3.1': dependencies: @@ -16015,7 +15912,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.1 three: 0.182.0 - '@photostructure/tz-lookup@11.3.0': {} + '@photostructure/tz-lookup@11.4.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -16316,68 +16213,67 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.9(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.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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/adapter-static@3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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.50.0)(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.10.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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': 6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0 - svelte-parse-markup: 0.1.5(svelte@5.50.0) - 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) + svelte: 5.53.0 + svelte-parse-markup: 0.1.5(svelte@5.53.0) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-imagetools: 9.0.3(rollup@4.55.1) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.9(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(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/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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: 3.0.1 sirv: 3.0.2 - svelte: 5.50.0 - 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.53.0 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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.50.0)(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-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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': 6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.50.0 - 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.53.0 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0)(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.50.0)(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-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.50.0 - 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)) + svelte: 5.53.0 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -16535,73 +16431,73 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/node@4.1.18': + '@tailwindcss/node@4.2.0': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.19.0 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.18 + tailwindcss: 4.2.0 - '@tailwindcss/oxide-android-arm64@4.1.18': + '@tailwindcss/oxide-android-arm64@4.2.0': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.18': + '@tailwindcss/oxide-darwin-arm64@4.2.0': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.18': + '@tailwindcss/oxide-darwin-x64@4.2.0': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.18': + '@tailwindcss/oxide-freebsd-x64@4.2.0': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.18': + '@tailwindcss/oxide-linux-x64-musl@4.2.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.18': + '@tailwindcss/oxide-wasm32-wasi@4.2.0': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': optional: true - '@tailwindcss/oxide@4.1.18': + '@tailwindcss/oxide@4.2.0': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + '@tailwindcss/oxide-android-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-x64': 4.2.0 + '@tailwindcss/oxide-freebsd-x64': 4.2.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-x64-musl': 4.2.0 + '@tailwindcss/oxide-wasm32-wasi': 4.2.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 - '@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))': + '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.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/node': 4.2.0 + '@tailwindcss/oxide': 4.2.0 + tailwindcss: 4.2.0 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/dom@10.4.1': dependencies: @@ -16623,18 +16519,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.50.0)': + '@testing-library/svelte-core@1.0.0(svelte@5.53.0)': dependencies: - svelte: 5.50.0 + svelte: 5.53.0 - '@testing-library/svelte@5.3.1(svelte@5.50.0)(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.5.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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.50.0) - svelte: 5.50.0 + '@testing-library/svelte-core': 1.0.0(svelte@5.53.0) + svelte: 5.53.0 optionalDependencies: - 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.5.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.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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: @@ -16649,9 +16545,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': @@ -16883,7 +16776,7 @@ snapshots: '@types/node': 24.10.13 '@types/ssh2': 1.15.5 - '@types/dockerode@3.3.47': + '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 '@types/node': 24.10.13 @@ -16901,6 +16794,8 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 + '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -17065,12 +16960,12 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.2.3': + '@types/node@25.3.0': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 optional: true - '@types/nodemailer@7.0.9': + '@types/nodemailer@7.0.10': dependencies: '@types/node': 24.10.13 @@ -17117,21 +17012,21 @@ snapshots: '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@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.13 + '@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.13 + '@types/react': 19.2.14 - '@types/react@19.2.13': + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -17212,8 +17107,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': {} @@ -17235,15 +17129,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.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.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/parser': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 10.0.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17251,58 +17145,58 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.54.0': + '@typescript-eslint/scope-manager@8.56.0': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.54.0': {} + '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 9.0.6 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17310,27 +17204,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@10.0.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.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.54.0': + '@typescript-eslint/visitor-keys@8.56.0': dependencies: - '@typescript-eslint/types': 8.54.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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 @@ -17345,11 +17239,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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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.2.3)(happy-dom@20.5.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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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 @@ -17364,7 +17258,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.2.3)(happy-dom@20.5.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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17376,21 +17270,21 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(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@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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@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: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.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: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -17502,13 +17396,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.50.0)': + '@zoom-image/svelte@0.3.9(svelte@5.53.0)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.50.0 - - abab@2.0.6: - optional: true + svelte: 5.53.0 abbrev@1.1.1: {} @@ -17528,29 +17419,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: {} @@ -17575,9 +17460,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: @@ -17587,9 +17472,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: @@ -17599,6 +17484,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -17606,6 +17498,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 @@ -17750,7 +17649,7 @@ snapshots: autoprefixer@10.4.24(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001774 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -17801,13 +17700,15 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.2: {} - bare-fs@4.5.2: + bare-fs@4.5.4: dependencies: bare-events: 2.8.2 bare-path: 3.0.0 - bare-stream: 2.7.0(bare-events@2.8.2) + bare-stream: 2.8.0(bare-events@2.8.2) bare-url: 2.3.2 fast-fifo: 1.3.2 transitivePeerDependencies: @@ -17823,9 +17724,10 @@ snapshots: bare-os: 3.6.2 optional: true - bare-stream@2.7.0(bare-events@2.8.2): + bare-stream@2.8.0(bare-events@2.8.2): dependencies: streamx: 2.23.0 + teex: 1.0.1 optionalDependencies: bare-events: 2.8.2 transitivePeerDependencies: @@ -17844,7 +17746,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - batch-cluster@16.0.0: {} + batch-cluster@17.3.1: {} batch@0.6.1: {} @@ -17866,15 +17768,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0): 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.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.0) - svelte: 5.50.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.0) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0) + svelte: 5.53.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -17954,6 +17856,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.3: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -17961,7 +17867,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001774 electron-to-chromium: 1.5.286 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -17987,13 +17893,13 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.67.3: + bullmq@5.69.3: dependencies: cron-parser: 4.9.0 ioredis: 5.9.2 msgpackr: 1.11.5 node-abort-controller: 3.1.1 - semver: 7.7.3 + semver: 7.7.4 tslib: 2.8.1 uuid: 11.1.0 transitivePeerDependencies: @@ -18078,16 +17984,16 @@ snapshots: caniuse-api@3.0.0: dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001774 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001774: {} canvas@2.11.2: dependencies: '@mapbox/node-pre-gyp': 1.0.11 - nan: 2.24.0 + nan: 2.25.0 simple-get: 3.1.1 transitivePeerDependencies: - encoding @@ -18097,7 +18003,7 @@ snapshots: canvas@2.11.2(encoding@0.1.13): dependencies: '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) - nan: 2.24.0 + nan: 2.25.0 simple-get: 3.1.1 transitivePeerDependencies: - encoding @@ -18202,7 +18108,7 @@ snapshots: dependencies: consola: 3.4.2 - cjs-module-lexer@1.4.3: {} + cjs-module-lexer@2.2.0: {} class-transformer@0.5.1: {} @@ -18475,7 +18381,7 @@ snapshots: cpu-features@0.0.10: dependencies: buildcheck: 0.0.7 - nan: 2.24.0 + nan: 2.25.0 optional: true crc-32@1.2.2: {} @@ -18583,8 +18489,6 @@ snapshots: css.escape@1.5.1: {} - csscolorparser@1.0.3: {} - cssdb@8.5.2: {} cssesc@3.0.0: {} @@ -18648,17 +18552,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 @@ -18856,13 +18749,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 @@ -18977,7 +18863,7 @@ snapshots: transitivePeerDependencies: - supports-color - devalue@5.6.2: {} + devalue@5.6.3: {} devlop@1.1.0: dependencies: @@ -19010,7 +18896,7 @@ snapshots: dependencies: '@leichtgewicht/ip-codec': 2.0.5 - docker-compose@1.3.0: + docker-compose@1.3.1: dependencies: yaml: 2.8.2 @@ -19035,9 +18921,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.13)(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.13)(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 @@ -19079,11 +18965,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 @@ -19117,7 +18998,7 @@ snapshots: dependencies: is-obj: 2.0.0 - dotenv@17.2.4: {} + dotenv@17.3.1: {} dunder-proto@1.0.1: dependencies: @@ -19127,8 +19008,6 @@ snapshots: duplexer@0.1.2: {} - earcut@2.2.4: {} - earcut@3.0.2: {} eastasianwidth@0.2.0: {} @@ -19205,6 +19084,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + env-paths@2.2.1: {} err-code@2.0.3: {} @@ -19267,7 +19148,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 @@ -19367,46 +19248,37 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: + eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)): dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - optional: true + eslint: 10.0.2(jiti@2.6.1) - 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.1.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-compat@6.2.0(eslint@10.0.2(jiti@2.6.1)): dependencies: '@mdn/browser-compat-data': 6.1.5 ast-metadata-inferer: 0.8.1 browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 - eslint: 9.39.2(jiti@2.6.1) + caniuse-lite: 1.0.30001774 + eslint: 10.0.2(jiti@2.6.1) find-up: 5.0.0 globals: 15.15.0 lodash.memoize: 4.1.2 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.1): + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.0.2(jiti@2.6.1)))(eslint@10.0.2(jiti@2.6.1))(prettier@3.8.1): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) 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-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.0): + eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.2(jiti@2.6.1) esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 @@ -19414,23 +19286,21 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.4 - svelte-eslint-parser: 1.4.1(svelte@5.50.0) + svelte-eslint-parser: 1.4.1(svelte@5.53.0) optionalDependencies: - svelte: 5.50.0 + svelte: 5.53.0 transitivePeerDependencies: - ts-node - eslint-plugin-unicorn@62.0.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-unicorn@63.0.0(eslint@10.0.2(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@eslint/plugin-kit': 0.4.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) change-case: 5.4.4 ci-info: 4.3.1 clean-regexp: 1.0.0 core-js-compat: 3.47.0 - eslint: 9.39.2(jiti@2.6.1) - esquery: 1.6.0 + eslint: 10.0.2(jiti@2.6.1) find-up-simple: 1.0.1 globals: 16.5.0 indent-string: 5.0.0 @@ -19452,33 +19322,39 @@ snapshots: esrecurse: 4.3.0 estraverse: 5.3.0 + eslint-scope@9.1.1: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + eslint-visitor-keys@3.4.3: {} eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.2(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 - '@eslint/plugin-kit': 0.4.1 + '@eslint/config-array': 0.23.2 + '@eslint/config-helpers': 0.5.2 + '@eslint/core': 1.1.0 + '@eslint/plugin-kit': 0.6.0 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 - chalk: 4.1.2 + ajv: 6.14.0 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 + eslint-scope: 9.1.1 + eslint-visitor-keys: 5.0.1 + espree: 11.1.1 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -19488,8 +19364,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 10.2.2 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -19508,13 +19383,19 @@ 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 + espree@11.1.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -19573,7 +19454,7 @@ snapshots: eta@2.2.0: {} - eta@4.5.0: {} + eta@4.5.1: {} etag@1.8.1: {} @@ -19613,21 +19494,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.45.0: + exiftool-vendored.exe@13.51.0: optional: true - exiftool-vendored.pl@13.45.0: {} + exiftool-vendored.pl@13.51.0: {} - exiftool-vendored@34.3.0: + exiftool-vendored@35.10.1: dependencies: - '@photostructure/tz-lookup': 11.3.0 + '@photostructure/tz-lookup': 11.4.0 '@types/luxon': 3.7.1 - batch-cluster: 16.0.0 - exiftool-vendored.pl: 13.45.0 + batch-cluster: 17.3.1 + exiftool-vendored.pl: 13.51.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.45.0 + exiftool-vendored.exe: 13.51.0 expect-type@1.3.0: {} @@ -19714,10 +19595,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 @@ -19984,8 +19865,6 @@ snapshots: pbf: 3.3.0 shapefile: 0.6.6 - geojson-vt@3.2.1: {} - geojson@0.5.0: {} get-caller-file@2.0.5: {} @@ -20042,7 +19921,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 + minimatch: 9.0.6 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -20081,12 +19960,12 @@ snapshots: dependencies: ini: 2.0.0 - globals@14.0.0: {} - globals@15.15.0: {} globals@16.5.0: {} + globals@17.3.0: {} + globalyzer@0.1.0: {} globby@11.1.0: @@ -20135,8 +20014,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 @@ -20154,12 +20031,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.5.0: + happy-dom@20.6.3: dependencies: '@types/node': 24.10.13 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 - entities: 4.5.0 + entities: 7.0.1 whatwg-mimetype: 3.0.0 ws: 8.19.0 transitivePeerDependencies: @@ -20359,11 +20236,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 @@ -20461,15 +20333,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 @@ -20561,11 +20424,11 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@2.0.0: + import-in-the-middle@2.0.6: dependencies: - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) - cjs-module-lexer: 1.4.3 + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 import-lazy@4.0.0: {} @@ -20649,6 +20512,20 @@ snapshots: transitivePeerDependencies: - supports-color + ioredis@5.9.3: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -20870,42 +20747,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 @@ -21036,8 +20877,6 @@ snapshots: dependencies: commander: 8.3.0 - kdbush@3.0.0: {} - kdbush@4.0.2: {} keygrip@1.1.0: @@ -21081,12 +20920,20 @@ snapshots: type-is: 2.0.1 vary: 1.1.2 + kysely-postgres-js@3.0.0(kysely@0.28.11)(postgres@3.4.8): + dependencies: + kysely: 0.28.11 + optionalDependencies: + postgres: 3.4.8 + kysely-postgres-js@3.0.0(kysely@0.28.2)(postgres@3.4.8): dependencies: kysely: 0.28.2 optionalDependencies: postgres: 3.4.8 + kysely@0.28.11: {} + kysely@0.28.2: {} langium@3.3.1: @@ -21125,54 +20972,54 @@ snapshots: libphonenumber-js@1.12.31: {} - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.31.1: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.31.1: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.31.1: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.31.1: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.31.1: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.31.1: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.31.1: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.31.1: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.31.1: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.31.1: optional: true - lightningcss@1.30.2: + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 lilconfig@2.1.0: {} @@ -21232,8 +21079,6 @@ snapshots: lodash.memoize@4.1.2: {} - lodash.merge@4.6.2: {} - lodash.once@4.1.1: {} lodash.uniq@4.5.0: {} @@ -21329,32 +21174,7 @@ snapshots: 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.17.0: + maplibre-gl@5.18.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -21393,7 +21213,7 @@ snapshots: marked@16.4.2: {} - marked@17.0.1: {} + marked@17.0.3: {} math-intrinsics@1.1.0: {} @@ -21788,8 +21608,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 @@ -21999,6 +21819,10 @@ snapshots: dependencies: '@isaacs/brace-expansion': 5.0.1 + minimatch@10.2.2: + dependencies: + brace-expansion: 5.0.3 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -22007,9 +21831,9 @@ snapshots: dependencies: brace-expansion: 2.0.2 - minimatch@9.0.5: + minimatch@9.0.6: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.3 minimist@1.2.8: {} @@ -22064,9 +21888,11 @@ snapshots: mkdirp@1.0.4: {} + mkdirp@3.0.1: {} + 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 @@ -22132,7 +21958,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nan@2.24.0: + nan@2.25.0: optional: true nanoid@3.3.11: {} @@ -22158,12 +21984,12 @@ snapshots: neo-async@2.6.2: {} - 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): + nest-commander@3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@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.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) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(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) @@ -22172,25 +21998,25 @@ snapshots: - '@types/node' - typescript - 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): + nestjs-cls@5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(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.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-kysely@3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2): dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(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.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-otel@7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14): dependencies: - '@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/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(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 @@ -22316,7 +22142,7 @@ snapshots: pkg-types: 2.3.0 tinyexec: 0.3.2 - oauth4webapi@3.8.3: {} + oauth4webapi@3.8.5: {} object-assign@4.1.1: {} @@ -22341,12 +22167,12 @@ snapshots: obug@2.1.1: {} - oidc-provider@9.6.0: + oidc-provider@9.6.1: dependencies: '@koa/cors': 5.0.0 - '@koa/router': 15.1.0(koa@3.1.1) + '@koa/router': 15.3.0(koa@3.1.1) debug: 4.4.3 - eta: 4.5.0 + eta: 4.5.1 jose: 6.1.3 jsesc: 3.1.0 koa: 3.1.1 @@ -22389,10 +22215,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: @@ -23187,8 +23013,6 @@ snapshots: postgres@3.4.8: {} - potpack@1.0.2: {} - potpack@2.1.0: {} prelude-ls@1.2.1: {} @@ -23206,10 +23030,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.0): + prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.0): dependencies: prettier: 3.8.1 - svelte: 5.50.0 + svelte: 5.53.0 prettier@3.8.1: {} @@ -23262,9 +23086,12 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 - properties-reader@2.3.0: + properties-reader@3.0.1: dependencies: - mkdirp: 1.0.4 + '@kwsites/file-exists': 1.1.1 + mkdirp: 3.0.1 + transitivePeerDependencies: + - supports-color property-information@5.6.0: dependencies: @@ -23311,11 +23138,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 @@ -23339,17 +23161,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: {} @@ -23528,10 +23345,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 @@ -23836,14 +23653,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.0): + runed@0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.50.0 + svelte: 5.53.0 optionalDependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -23869,7 +23686,7 @@ snapshots: dependencies: truncate-utf8-bytes: 1.0.2 - sanitize-html@2.17.0: + sanitize-html@2.17.1: dependencies: deepmerge: 4.3.1 escape-string-regexp: 4.0.0 @@ -23910,9 +23727,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: {} @@ -23938,8 +23755,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} - semver@7.7.4: {} send@0.19.2: @@ -24141,7 +23956,7 @@ snapshots: simple-icons@15.22.0: {} - simple-icons@16.4.0: {} + simple-icons@16.9.0: {} sirv@2.0.4: dependencies: @@ -24287,7 +24102,7 @@ snapshots: sprintf-js@1.0.3: {} - sql-formatter@15.7.0: + sql-formatter@15.7.2: dependencies: argparse: 2.0.1 nearley: 2.20.1 @@ -24305,7 +24120,7 @@ snapshots: bcrypt-pbkdf: 1.0.2 optionalDependencies: cpu-features: 0.0.10 - nan: 2.24.0 + nan: 2.25.0 ssri@13.0.1: dependencies: @@ -24447,10 +24262,6 @@ snapshots: transitivePeerDependencies: - supports-color - supercluster@7.1.5: - dependencies: - kdbush: 3.0.0 - supercluster@8.0.1: dependencies: kdbush: 4.0.2 @@ -24473,23 +24284,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.50.0): + svelte-awesome@3.3.5(svelte@5.53.0): dependencies: - svelte: 5.50.0 + svelte: 5.53.0 - svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3): + svelte-check@4.4.1(picomatch@4.0.3)(svelte@5.53.0)(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.50.0 + svelte: 5.53.0 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.50.0): + svelte-eslint-parser@1.4.1(svelte@5.53.0): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -24498,7 +24309,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.50.0 + svelte: 5.53.0 svelte-floating-ui@1.5.8: dependencies: @@ -24511,7 +24322,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.50.0): + svelte-i18n@4.0.1(svelte@5.53.0): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24519,10 +24330,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.50.0 + svelte: 5.53.0 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.50.0): + svelte-jsoneditor@3.11.0(svelte@5.53.0): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24537,7 +24348,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 @@ -24549,52 +24360,53 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.50.0 - svelte-awesome: 3.3.5(svelte@5.50.0) + svelte: 5.53.0 + svelte-awesome: 3.3.5(svelte@5.53.0) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.50.0): + svelte-maplibre@1.2.6(svelte@5.53.0): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.17.0 + maplibre-gl: 5.18.0 pmtiles: 3.2.1 - svelte: 5.50.0 + svelte: 5.53.0 - svelte-parse-markup@0.1.5(svelte@5.50.0): + svelte-parse-markup@0.1.5(svelte@5.53.0): dependencies: - svelte: 5.50.0 + svelte: 5.53.0 - svelte-persisted-store@0.12.0(svelte@5.50.0): + svelte-persisted-store@0.12.0(svelte@5.53.0): dependencies: - svelte: 5.50.0 + svelte: 5.53.0 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.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.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.50.0)(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.50.0) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.0) style-to-object: 1.0.14 - svelte: 5.50.0 + svelte: 5.53.0 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.50.0: + svelte@5.53.0: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.9(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.3 is-reference: 3.0.3 @@ -24639,9 +24451,9 @@ snapshots: tailwind-merge@3.4.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18): + tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.0): dependencies: - tailwindcss: 4.1.18 + tailwindcss: 4.2.0 optionalDependencies: tailwind-merge: 3.4.0 @@ -24687,7 +24499,7 @@ snapshots: - tsx - yaml - tailwindcss@4.1.18: {} + tailwindcss@4.2.0: {} tapable@2.3.0: {} @@ -24703,7 +24515,7 @@ snapshots: pump: 3.0.3 tar-stream: 3.1.7 optionalDependencies: - bare-fs: 4.5.2 + bare-fs: 4.5.4 bare-path: 3.0.0 transitivePeerDependencies: - bare-abort-controller @@ -24744,6 +24556,14 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + teex@1.0.1: + dependencies: + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + 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 @@ -24767,7 +24587,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 @@ -24775,25 +24595,25 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 9.0.5 + minimatch: 9.0.6 - testcontainers@11.11.0: + testcontainers@11.12.0: dependencies: '@balena/dockerignore': 1.0.2 - '@types/dockerode': 3.3.47 + '@types/dockerode': 4.0.1 archiver: 7.0.1 async-lock: 1.4.1 byline: 5.0.0 debug: 4.4.3 - docker-compose: 1.3.0 + docker-compose: 1.3.1 dockerode: 4.0.9 get-port: 7.1.0 proper-lockfile: 4.1.2 - properties-reader: 2.3.0 + properties-reader: 3.0.1 ssh-remote-port-forward: 1.0.4 tar-fs: 3.1.1 tmp: 0.2.5 - undici: 7.18.0 + undici: 7.22.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -24859,8 +24679,6 @@ snapshots: tinypool@1.1.1: {} - tinyqueue@2.0.3: {} - tinyqueue@3.0.0: {} tinyrainbow@2.0.0: {} @@ -24896,14 +24714,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 @@ -24911,11 +24721,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 @@ -25004,13 +24809,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.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.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -25042,7 +24847,10 @@ snapshots: undici-types@7.16.0: {} - undici@7.18.0: {} + undici-types@7.18.2: + optional: true + + undici@7.22.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -25137,9 +24945,6 @@ 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: {} @@ -25156,7 +24961,7 @@ 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 @@ -25198,12 +25003,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 @@ -25278,7 +25077,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-imagetools@9.0.2(rollup@4.55.1): + vite-imagetools@9.0.3(rollup@4.55.1): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.55.1) imagetools-core: 9.1.0 @@ -25287,13 +25086,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@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-node@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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@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: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25308,13 +25107,13 @@ snapshots: - tsx - yaml - 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): + vite-node@3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.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: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25329,17 +25128,17 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.0(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.31.1)(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) - 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: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - 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@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -25351,13 +25150,13 @@ snapshots: '@types/node': 24.10.13 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 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@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -25366,28 +25165,28 @@ snapshots: rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 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)): + vitefu@1.1.1(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - 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: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(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@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@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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 @@ -25405,13 +25204,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - 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-node: 3.2.4(@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: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.3 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -25427,11 +25226,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.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.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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@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@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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 @@ -25449,13 +25248,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - 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-node: 3.2.4(@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: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(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': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.3 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25471,11 +25270,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.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.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(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.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/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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 @@ -25493,13 +25292,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - 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) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(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.2.3 - happy-dom: 20.5.0 + '@types/node': 25.3.0 + happy-dom: 20.6.3 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25532,19 +25331,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 @@ -25575,7 +25363,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 @@ -25665,8 +25453,8 @@ 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.19.0 @@ -25697,8 +25485,8 @@ 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.19.0 @@ -25741,11 +25529,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 @@ -25756,12 +25539,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 @@ -25849,9 +25626,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 80427642e5..9b1acc91fb 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,13 +28,14 @@ "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" }, "dependencies": { "@extism/extism": "2.0.0-rc13", + "@immich/sql-tools": "^0.2.0", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", @@ -45,14 +46,14 @@ "@nestjs/websockets": "^11.0.4", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.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/exporter-prometheus": "^0.212.0", + "@opentelemetry/instrumentation-http": "^0.212.0", + "@opentelemetry/instrumentation-ioredis": "^0.60.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.58.0", + "@opentelemetry/instrumentation-pg": "^0.64.0", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1", - "@opentelemetry/sdk-node": "^0.211.0", + "@opentelemetry/sdk-node": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", @@ -70,7 +71,7 @@ "cookie": "^1.0.2", "cookie-parser": "^1.4.7", "cron": "4.4.0", - "exiftool-vendored": "^34.3.0", + "exiftool-vendored": "^35.0.0", "express": "^5.1.0", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", @@ -116,7 +117,7 @@ "validator": "^13.12.0" }, "devDependencies": { - "@eslint/js": "^9.8.0", + "@eslint/js": "^10.0.0", "@nestjs/cli": "^11.0.2", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.4", @@ -135,7 +136,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", @@ -146,11 +147,11 @@ "@types/ua-parser-js": "^0.7.36", "@types/validator": "^13.15.2", "@vitest/coverage-v8": "^3.0.0", - "eslint": "^9.14.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^62.0.0", - "globals": "^16.0.0", + "eslint-plugin-unicorn": "^63.0.0", + "globals": "^17.0.0", "mock-fs": "^5.2.0", "node-gyp": "^12.0.0", "pngjs": "^7.0.0", @@ -167,7 +168,7 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" }, "overrides": { "sharp": "^0.34.5" diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index 588f358023..bfa0f1733c 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -1,16 +1,15 @@ #!/usr/bin/env node process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; +import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools'; import { Kysely, sql } from 'kysely'; import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { basename, dirname, extname, join } from 'node:path'; -import postgres from 'postgres'; import { ConfigRepository } from 'src/repositories/config.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import 'src/schema'; -import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; -import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database'; +import { getKyselyConfig } from 'src/utils/database'; const main = async () => { const command = process.argv[2]; @@ -130,10 +129,9 @@ const create = (path: string, up: string[], down: string[]) => { const compare = async () => { const configRepository = new ConfigRepository(); const { database } = configRepository.getEnv(); - const db = postgres(asPostgresConnectionConfig(database.config)); const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); - const target = await schemaFromDatabase(db, {}); + const target = await schemaFromDatabase({ connection: database.config }); console.log(source.warnings.join('\n')); diff --git a/server/src/commands/schema-check.ts b/server/src/commands/schema-check.ts index c6e90fd9ca..e0ccae8469 100644 --- a/server/src/commands/schema-check.ts +++ b/server/src/commands/schema-check.ts @@ -1,7 +1,7 @@ +import { asHuman } from '@immich/sql-tools'; 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', diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 197e06d02d..2893a27539 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -369,6 +369,31 @@ describe(AssetController.name, () => { expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); }); + it('should check the action and parameters discriminator', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/${factory.uuid()}/edits`) + .send({ + edits: [ + { + action: 'rotate', + parameters: { + x: 0, + y: 0, + width: 100, + height: 100, + }, + }, + ], + }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining([expect.stringContaining('parameters.angle must be one of the following values')]), + ), + ); + }); + it('should require at least one edit', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/assets/${factory.uuid()}/edits`) diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8eb3a5ce44..2024760975 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -20,7 +20,7 @@ import { UpdateAssetDto, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; +import { AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { ApiTag, Permission, RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; @@ -235,7 +235,7 @@ export class AssetController { description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.', history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), }) - getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getAssetEdits(auth, id); } @@ -249,8 +249,8 @@ export class AssetController { editAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Body() dto: AssetEditActionListDto, - ): Promise { + @Body() dto: AssetEditsCreateDto, + ): Promise { return this.service.editAsset(auth, id, dto); } 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/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index 8629b6c799..820819ee6e 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -51,6 +51,20 @@ describe(MemoryController.name, () => { errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']), ); }); + + it('should accept showAt and hideAt', async () => { + const { status } = await request(ctx.getHttpServer()) + .post('/memories') + .send({ + type: 'on_this_day', + data: { year: 2020 }, + memoryAt: new Date(2021).toISOString(), + showAt: new Date(2022).toISOString(), + hideAt: new Date(2023).toISOString(), + }); + + expect(status).toBe(201); + }); }); describe('GET /memories/statistics', () => { diff --git a/server/src/database.ts b/server/src/database.ts index dd979fdea6..ec614df9e0 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -352,6 +352,7 @@ export const columns = { 'asset_file.type', 'asset_file.isEdited', 'asset_file.isProgressive', + 'asset_file.isTransparent', ], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authApiKey: ['api_key.id', 'api_key.permissions'], @@ -436,6 +437,13 @@ export const columns = { 'asset_exif.rating', 'asset_exif.fps', ], + syncAssetEdit: [ + 'asset_edit.id', + 'asset_edit.assetId', + 'asset_edit.sequence', + 'asset_edit.action', + 'asset_edit.parameters', + ], exif: [ 'asset_exif.assetId', 'asset_exif.autoStackId', diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 87a3900a7f..695adb4a36 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,10 +1,10 @@ +import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools'; import { SetMetadata, applyDecorators } from '@nestjs/common'; import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { EmitEvent } from 'src/repositories/event.repository'; import { immich_uuid_v7, updated_at } from 'src/schema/functions'; -import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools'; import { setUnion } from 'src/utils/set'; const GeneratedUuidV7Column = (options: Omit = {}) => diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index df02a0cdea..a76df4abaa 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -25,7 +25,10 @@ export class SanitizedAssetResponseDto { id!: string; @ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' }) type!: AssetType; - @ApiProperty({ description: 'Thumbhash for thumbnail generation' }) + @ApiProperty({ + description: + 'Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.', + }) thumbhash!: string | null; @ApiPropertyOptional({ description: 'Original MIME type' }) originalMimeType?: string; 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..8217fec41c 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -1,7 +1,7 @@ import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; -import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer'; +import { Type } from 'class-transformer'; import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator'; -import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation'; +import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation'; export enum AssetEditAction { Crop = 'crop', @@ -48,36 +48,7 @@ export class MirrorParameters { axis!: MirrorAxis; } -class AssetEditActionBase { - @IsEnum(AssetEditAction) - @ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction', description: 'Type of edit action to perform' }) - action!: AssetEditAction; -} - -export class AssetEditActionCrop extends AssetEditActionBase { - @ValidateNested() - @Type(() => CropParameters) - // Description lives on schema to avoid duplication - @ApiProperty({ description: undefined }) - parameters!: CropParameters; -} - -export class AssetEditActionRotate extends AssetEditActionBase { - @ValidateNested() - @Type(() => RotateParameters) - // Description lives on schema to avoid duplication - @ApiProperty({ description: undefined }) - parameters!: RotateParameters; -} - -export class AssetEditActionMirror extends AssetEditActionBase { - @ValidateNested() - @Type(() => MirrorParameters) - // Description lives on schema to avoid duplication - @ApiProperty({ description: undefined }) - parameters!: MirrorParameters; -} - +export type AssetEditParameters = CropParameters | RotateParameters | MirrorParameters; export type AssetEditActionItem = | { action: AssetEditAction.Crop; @@ -92,39 +63,49 @@ export type AssetEditActionItem = parameters: MirrorParameters; }; -export type AssetEditActionParameter = { - [AssetEditAction.Crop]: CropParameters; - [AssetEditAction.Rotate]: RotateParameters; - [AssetEditAction.Mirror]: MirrorParameters; +@ApiExtraModels(CropParameters, RotateParameters, MirrorParameters) +export class AssetEditActionItemDto { + @ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' }) + action!: AssetEditAction; + + @ApiProperty({ + description: 'List of edit actions to apply (crop, rotate, or mirror)', + anyOf: [CropParameters, RotateParameters, MirrorParameters].map((type) => ({ + $ref: getSchemaPath(type), + })), + }) + @ValidateNested() + @Type((options) => actionParameterMap[options?.object.action as keyof AssetEditActionParameter]) + parameters!: AssetEditActionItem['parameters']; +} + +export class AssetEditActionItemResponseDto extends AssetEditActionItemDto { + @ValidateUUID() + id!: string; +} + +export type AssetEditActionParameter = typeof actionParameterMap; +const actionParameterMap = { + [AssetEditAction.Crop]: CropParameters, + [AssetEditAction.Rotate]: RotateParameters, + [AssetEditAction.Mirror]: MirrorParameters, }; -type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror; -const actionToClass: Record> = { - [AssetEditAction.Crop]: AssetEditActionCrop, - [AssetEditAction.Rotate]: AssetEditActionRotate, - [AssetEditAction.Mirror]: AssetEditActionMirror, -} as const; - -const getActionClass = (item: { action: AssetEditAction }): ClassConstructor => - actionToClass[item.action]; - -@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop) -export class AssetEditActionListDto { - /** list of edits */ +export class AssetEditsCreateDto { @ArrayMinSize(1) @IsUniqueEditActions() @ValidateNested({ each: true }) - @Transform(({ value: edits }) => - Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits, - ) - @ApiProperty({ - anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })), - description: 'List of edit actions to apply (crop, rotate, or mirror)', - }) - edits!: AssetEditActionItem[]; + @Type(() => AssetEditActionItemDto) + @ApiProperty({ description: 'List of edit actions to apply (crop, rotate, or mirror)' }) + edits!: AssetEditActionItemDto[]; } -export class AssetEditsDto extends AssetEditActionListDto { - @ValidateUUID({ description: 'Asset ID to apply edits to' }) +export class AssetEditsResponseDto { + @ValidateUUID({ description: 'Asset ID these edits belong to' }) assetId!: string; + + @ApiProperty({ + description: 'List of edit actions applied to the asset', + }) + edits!: AssetEditActionItemResponseDto[]; } diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index e088a33413..b04366c273 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -1,8 +1,17 @@ import { Transform, Type } from 'class-transformer'; import { IsEnum, IsInt, IsString, Matches } from 'class-validator'; -import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum'; +import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum'; import { IsIPRange, Optional, ValidateBoolean } from 'src/validation'; +// TODO import from sql-tools once the swagger plugin supports external enums +enum DatabaseSslMode { + Disable = 'disable', + Allow = 'allow', + Prefer = 'prefer', + Require = 'require', + VerifyFull = 'verify-full', +} + export class EnvDto { @IsInt() @Optional() diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 0d73c19b20..edf65ef583 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; import { Memory } from 'src/database'; +import { HistoryBuilder } from 'src/decorators'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOrderWithRandom, MemoryType } from 'src/enum'; @@ -77,6 +78,20 @@ export class MemoryCreateDto extends MemoryBaseDto { @ValidateDate({ description: 'Memory date' }) memoryAt!: Date; + @ValidateDate({ + optional: true, + description: 'Date when memory should be shown', + history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'), + }) + showAt?: Date; + + @ValidateDate({ + optional: true, + description: 'Date when memory should be hidden', + history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'), + }) + hideAt?: Date; + @ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' }) assetIds?: string[]; } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 59d7d373f0..9a1332d303 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AlbumUserRole, AssetOrder, @@ -218,6 +219,24 @@ export class SyncAssetExifV1 { fps!: number | null; } +@ExtraModel() +export class SyncAssetEditV1 { + id!: string; + assetId!: string; + + @ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' }) + action!: AssetEditAction; + parameters!: object; + + @ApiProperty({ type: 'integer' }) + sequence!: number; +} + +@ExtraModel() +export class SyncAssetEditDeleteV1 { + editId!: string; +} + @ExtraModel() export class SyncAssetMetadataV1 { @ApiProperty({ description: 'Asset ID' }) @@ -422,6 +441,20 @@ export class SyncAssetFaceV1 { sourceType!: string; } +@ExtraModel() +export class SyncAssetFaceV2 extends SyncAssetFaceV1 { + @ApiProperty({ description: 'Face deleted at' }) + deletedAt!: Date | null; + @ApiProperty({ description: 'Is the face visible in the asset' }) + isVisible!: boolean; +} + +export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 { + const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2; + + return faceV1; +} + @ExtraModel() export class SyncAssetFaceDeleteV1 { @ApiProperty({ description: 'Asset face ID' }) @@ -466,6 +499,8 @@ export type SyncItem = { [SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1; [SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1; [SyncEntityType.AssetExifV1]: SyncAssetExifV1; + [SyncEntityType.AssetEditV1]: SyncAssetEditV1; + [SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1; [SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; @@ -497,6 +532,7 @@ export type SyncItem = { [SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; + [SyncEntityType.AssetFaceV2]: SyncAssetFaceV2; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 8f509754da..2aa9bd2aa6 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -720,6 +720,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AssetsV1 = 'AssetsV1', AssetExifsV1 = 'AssetExifsV1', + AssetEditsV1 = 'AssetEditsV1', AssetMetadataV1 = 'AssetMetadataV1', AuthUsersV1 = 'AuthUsersV1', MemoriesV1 = 'MemoriesV1', @@ -732,6 +733,7 @@ export enum SyncRequestType { UsersV1 = 'UsersV1', PeopleV1 = 'PeopleV1', AssetFacesV1 = 'AssetFacesV1', + AssetFacesV2 = 'AssetFacesV2', UserMetadataV1 = 'UserMetadataV1', } @@ -744,6 +746,8 @@ export enum SyncEntityType { AssetV1 = 'AssetV1', AssetDeleteV1 = 'AssetDeleteV1', AssetExifV1 = 'AssetExifV1', + AssetEditV1 = 'AssetEditV1', + AssetEditDeleteV1 = 'AssetEditDeleteV1', AssetMetadataV1 = 'AssetMetadataV1', AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1', @@ -790,6 +794,7 @@ export enum SyncEntityType { PersonDeleteV1 = 'PersonDeleteV1', AssetFaceV1 = 'AssetFaceV1', + AssetFaceV2 = 'AssetFaceV2', AssetFaceDeleteV1 = 'AssetFaceDeleteV1', UserMetadataV1 = 'UserMetadataV1', @@ -821,14 +826,6 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretBasic = 'client_secret_basic', } -export enum DatabaseSslMode { - Disable = 'disable', - Allow = 'allow', - Prefer = 'prefer', - Require = 'require', - VerifyFull = 'verify-full', -} - export enum AssetVisibility { Archive = 'archive', Timeline = 'timeline', diff --git a/server/src/main.ts b/server/src/main.ts index a8e3178a43..f2491f07bc 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -52,9 +52,9 @@ class Workers { try { const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode); return value?.isMaintenanceMode || false; - } catch (error) { + } catch (error: Error | any) { // Table doesn't exist (migrations haven't run yet) - if (error instanceof PostgresError && error.code === '42P01') { + if ((error as PostgresError).code === '42P01') { return false; } diff --git a/server/src/queries/asset.edit.repository.sql b/server/src/queries/asset.edit.repository.sql index 0cf62882db..44dca38031 100644 --- a/server/src/queries/asset.edit.repository.sql +++ b/server/src/queries/asset.edit.repository.sql @@ -9,6 +9,7 @@ rollback -- AssetEditRepository.getAll select + "id", "action", "parameters" from @@ -17,3 +18,17 @@ where "assetId" = $1 order by "sequence" asc + +-- AssetEditRepository.getWithSyncInfo +select + "asset_edit"."id", + "asset_edit"."assetId", + "asset_edit"."sequence", + "asset_edit"."action", + "asset_edit"."parameters" +from + "asset_edit" +where + "assetId" = $1 +order by + "sequence" asc diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index d990e0a304..54b3c92dd4 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -216,7 +216,8 @@ select "asset_file"."path", "asset_file"."type", "asset_file"."isEdited", - "asset_file"."isProgressive" + "asset_file"."isProgressive", + "asset_file"."isTransparent" from "asset_file" where 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/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index f817ad57b3..43c6a380bf 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -514,6 +514,38 @@ where order by "asset_exif"."updateId" asc +-- SyncRepository.assetEdit.getDeletes +select + "asset_edit_audit"."id", + "editId" +from + "asset_edit_audit" as "asset_edit_audit" + inner join "asset" on "asset"."id" = "asset_edit_audit"."assetId" +where + "asset_edit_audit"."id" < $1 + and "asset_edit_audit"."id" > $2 + and "asset"."ownerId" = $3 +order by + "asset_edit_audit"."id" asc + +-- SyncRepository.assetEdit.getUpserts +select + "asset_edit"."id", + "asset_edit"."assetId", + "asset_edit"."sequence", + "asset_edit"."action", + "asset_edit"."parameters", + "asset_edit"."updateId" +from + "asset_edit" as "asset_edit" + inner join "asset" on "asset"."id" = "asset_edit"."assetId" +where + "asset_edit"."updateId" < $1 + and "asset_edit"."updateId" > $2 + and "asset"."ownerId" = $3 +order by + "asset_edit"."updateId" asc + -- SyncRepository.assetFace.getDeletes select "asset_face_audit"."id", @@ -540,6 +572,8 @@ select "boundingBoxX2", "boundingBoxY2", "sourceType", + "isVisible", + "asset_face"."deletedAt", "asset_face"."updateId" from "asset_face" as "asset_face" diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index 28307d7c83..1e0d13f16c 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -31,7 +31,7 @@ export class ApiKeyRepository { } @GenerateSql({ params: [DummyValue.STRING] }) - getKey(hashedToken: string) { + getKey(hashedToken: Buffer) { return this.db .selectFrom('api_key') .select((eb) => [ diff --git a/server/src/repositories/asset-edit.repository.ts b/server/src/repositories/asset-edit.repository.ts index 088cb1ccff..164ebec6b6 100644 --- a/server/src/repositories/asset-edit.repository.ts +++ b/server/src/repositories/asset-edit.repository.ts @@ -1,18 +1,17 @@ import { Injectable } from '@nestjs/common'; import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetEditActionItem } from 'src/dtos/editing.dto'; +import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto'; import { DB } from 'src/schema'; @Injectable() export class AssetEditRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ - params: [DummyValue.UUID], - }) - replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { + @GenerateSql({ params: [DummyValue.UUID] }) + replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { return this.db.transaction().execute(async (trx) => { await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); @@ -20,23 +19,31 @@ export class AssetEditRepository { return trx .insertInto('asset_edit') .values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit }))) - .returning(['action', 'parameters']) - .execute() as Promise; + .returning(['id', 'action', 'parameters']) + .execute(); } return []; }); } - @GenerateSql({ - params: [DummyValue.UUID], - }) - getAll(assetId: string): Promise { + @GenerateSql({ params: [DummyValue.UUID] }) + getAll(assetId: string): Promise { return this.db .selectFrom('asset_edit') - .select(['action', 'parameters']) + .select(['id', 'action', 'parameters']) .where('assetId', '=', assetId) .orderBy('sequence', 'asc') - .execute() as Promise; + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getWithSyncInfo(assetId: string) { + return this.db + .selectFrom('asset_edit') + .select(columns.syncAssetEdit) + .where('assetId', '=', assetId) + .orderBy('sequence', 'asc') + .execute(); } } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1a060c4715..b58d852707 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'; @@ -902,7 +903,10 @@ export class AssetRepository { } async upsertFile( - file: Pick, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>, + file: Pick< + Insertable, + 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent' + >, ): Promise { await this.db .insertInto('asset_file') @@ -916,7 +920,10 @@ export class AssetRepository { } async upsertFiles( - files: Pick, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[], + files: Pick< + Insertable, + 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent' + >[], ): Promise { if (files.length === 0) { return; @@ -929,6 +936,7 @@ export class AssetRepository { oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ path: eb.ref('excluded.path'), isProgressive: eb.ref('excluded.isProgressive'), + isTransparent: eb.ref('excluded.isTransparent'), })), ) .execute(); @@ -1008,12 +1016,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 +1032,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 +1067,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/config.repository.ts b/server/src/repositories/config.repository.ts index 54a5d1987f..7e8082a582 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -1,3 +1,4 @@ +import { DatabaseConnectionParams } from '@immich/sql-tools'; import { RegisterQueueOptions } from '@nestjs/bullmq'; import { Inject, Injectable, Optional } from '@nestjs/common'; import { QueueOptions } from 'bullmq'; @@ -21,7 +22,7 @@ import { LogLevel, QueueName, } from 'src/enum'; -import { DatabaseConnectionParams, VectorExtension } from 'src/types'; +import { VectorExtension } from 'src/types'; import { setDifference } from 'src/utils/set'; export interface EnvData { @@ -184,7 +185,7 @@ const getEnv = (): EnvData => { try { redisConfig = JSON.parse(Buffer.from(redisUrl.slice(10), 'base64').toString()); } catch (error) { - throw new Error(`Failed to decode redis options: ${error}`); + throw new Error('Failed to decode redis options', { cause: error }); } } diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index bcd791ade2..9b093f6d79 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -23,7 +23,7 @@ export class CryptoRepository { } hashSha256(value: string) { - return createHash('sha256').update(value).digest('base64'); + return createHash('sha256').update(value).digest(); } verifySha256(value: string, encryptedValue: string, publicKey: string) { diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 17647d065d..06bdef5abf 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -1,3 +1,4 @@ +import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools'; import { Injectable } from '@nestjs/common'; import AsyncLock from 'async-lock'; import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely'; @@ -21,7 +22,6 @@ 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'; @@ -248,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); @@ -289,7 +289,8 @@ export class DatabaseRepository { async getSchemaDrift() { const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); - const target = await schemaFromDatabase(this.db, {}); + const { database } = this.configRepository.getEnv(); + const target = await schemaFromDatabase({ connection: database.config }); const drift = schemaDiff(source, target, { tables: { ignoreExtra: true }, diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 33025e73cf..7b0b30583d 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -243,23 +243,26 @@ export class MediaRepository { bitrate: this.parseInt(results.format.bit_rate), }, videoStreams: results.streams - .filter((stream) => stream.codec_type === 'video') - .filter((stream) => !stream.disposition?.attached_pic) - .map((stream) => ({ - index: stream.index, - height: this.parseInt(stream.height), - width: this.parseInt(stream.width), - codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, - codecType: stream.codec_type, - frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), - rotation: this.parseInt(stream.rotation), - isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: this.parseInt(stream.bit_rate), - pixelFormat: stream.pix_fmt || 'yuv420p', - colorPrimaries: stream.color_primaries, - colorSpace: stream.color_space, - colorTransfer: stream.color_transfer, - })), + .filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic) + .map((stream) => { + const height = this.parseInt(stream.height); + const dar = this.getDar(stream.display_aspect_ratio); + return { + index: stream.index, + height, + width: dar ? Math.round(height * dar) : this.parseInt(stream.width), + codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, + codecType: stream.codec_type, + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), + isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', + bitrate: this.parseInt(stream.bit_rate), + pixelFormat: stream.pix_fmt || 'yuv420p', + colorPrimaries: stream.color_primaries, + colorSpace: stream.color_space, + colorTransfer: stream.color_transfer, + }; + }), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') .map((stream) => ({ @@ -309,9 +312,9 @@ export class MediaRepository { }); } - async getImageDimensions(input: string | Buffer): Promise { - const { width = 0, height = 0 } = await sharp(input).metadata(); - return { width, height }; + async getImageMetadata(input: string | Buffer): Promise { + const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata(); + return { width, height, isTransparent: hasAlpha }; } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { @@ -352,4 +355,15 @@ export class MediaRepository { private parseFloat(value: string | number | undefined): number { return Number.parseFloat(value as string) || 0; } + + private getDar(dar: string | undefined): number { + if (dar) { + const [darW, darH] = dar.split(':').map(Number); + if (darW && darH) { + return darW / darH; + } + } + + return 0; + } } 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/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index 4500094899..934706d5e1 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -69,7 +69,7 @@ export class ServerInfoRepository { return response.json(); } catch (error) { - throw new Error(`Failed to fetch GitHub release: ${error}`); + throw new Error('Failed to fetch GitHub release', { cause: error }); } } diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 52292b8e4a..e008943f21 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -48,7 +48,7 @@ export class SessionRepository { } @GenerateSql({ params: [DummyValue.STRING] }) - getByToken(token: string) { + getByToken(token: Buffer) { return this.db .selectFrom('session') .select((eb) => [ diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 511d7b589f..b2fa144ca4 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -53,6 +53,7 @@ export class SyncRepository { albumUser: AlbumUserSync; asset: AssetSync; assetExif: AssetExifSync; + assetEdit: AssetEditSync; assetFace: AssetFaceSync; assetMetadata: AssetMetadataSync; authUser: AuthUserSync; @@ -75,6 +76,7 @@ export class SyncRepository { this.albumUser = new AlbumUserSync(this.db); this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); + this.assetEdit = new AssetEditSync(this.db); this.assetFace = new AssetFaceSync(this.db); this.assetMetadata = new AssetMetadataSync(this.db); this.authUser = new AuthUserSync(this.db); @@ -91,7 +93,7 @@ export class SyncRepository { } } -class BaseSync { +export class BaseSync { constructor(protected db: Kysely) {} protected backfillQuery(t: T, { nowId, beforeUpdateId, afterUpdateId }: SyncBackfillOptions) { @@ -479,6 +481,8 @@ class AssetFaceSync extends BaseSync { 'boundingBoxX2', 'boundingBoxY2', 'sourceType', + 'isVisible', + 'asset_face.deletedAt', 'asset_face.updateId', ]) .leftJoin('asset', 'asset.id', 'asset_face.assetId') @@ -499,6 +503,30 @@ class AssetExifSync extends BaseSync { } } +class AssetEditSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getDeletes(options: SyncQueryOptions) { + return this.auditQuery('asset_edit_audit', options) + .select(['asset_edit_audit.id', 'editId']) + .innerJoin('asset', 'asset.id', 'asset_edit_audit.assetId') + .where('asset.ownerId', '=', options.userId) + .stream(); + } + + cleanupAuditTable(daysAgo: number) { + return this.auditCleanup('asset_edit_audit', daysAgo); + } + + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getUpserts(options: SyncQueryOptions) { + return this.upsertQuery('asset_edit', options) + .select([...columns.syncAssetEdit, 'asset_edit.updateId']) + .innerJoin('asset', 'asset.id', 'asset_edit.assetId') + .where('asset.ownerId', '=', options.userId) + .stream(); + } +} + class MemorySync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getDeletes(options: SyncQueryOptions) { diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index bfed556895..235d2f2a84 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; +import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { handlePromiseError } from 'src/utils/misc'; @@ -37,7 +37,7 @@ export interface ClientEventMap { AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AppRestartV1: [AppRestartEvent]; - AssetEditReadyV1: [{ asset: SyncAssetV1 }]; + AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }]; } export type AuthFn = (client: Socket) => Promise; diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts index a1134df6bc..c68f152779 100644 --- a/server/src/schema/enums.ts +++ b/server/src/schema/enums.ts @@ -1,5 +1,5 @@ +import { registerEnum } from '@immich/sql-tools'; import { AssetStatus, AssetVisibility, SourceType } from 'src/enum'; -import { registerEnum } from 'src/sql-tools'; export const assets_status_enum = registerEnum({ name: 'assets_status_enum', diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index d7dabfef4c..d143e582ca 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -1,4 +1,4 @@ -import { registerFunction } from 'src/sql-tools'; +import { registerFunction } from '@immich/sql-tools'; export const immich_uuid_v7 = registerFunction({ name: 'immich_uuid_v7', @@ -286,3 +286,16 @@ export const asset_edit_delete = registerFunction({ END `, }); + +export const asset_edit_audit = registerFunction({ + name: 'asset_edit_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_edit_audit ("editId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END`, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 4dc3d40312..2426c2aab7 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -1,3 +1,4 @@ +import { Database, Extensions, Generated, Int8 } from '@immich/sql-tools'; import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; import { album_delete_audit, @@ -28,6 +29,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetEditAuditTable } from 'src/schema/tables/asset-edit-audit.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; @@ -72,7 +74,6 @@ import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; -import { Database, Extensions, Generated, Int8 } from 'src/sql-tools'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @Database({ name: 'immich' }) @@ -88,6 +89,7 @@ export class ImmichDatabase { ApiKeyTable, AssetAuditTable, AssetEditTable, + AssetEditAuditTable, AssetFaceTable, AssetFaceAuditTable, AssetMetadataTable, @@ -184,6 +186,7 @@ export interface DB { asset: AssetTable; asset_audit: AssetAuditTable; asset_edit: AssetEditTable; + asset_edit_audit: AssetEditAuditTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; 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/migrations/1771639515206-AddIsTransparentColumn.ts b/server/src/schema/migrations/1771639515206-AddIsTransparentColumn.ts new file mode 100644 index 0000000000..a19d102edb --- /dev/null +++ b/server/src/schema/migrations/1771639515206-AddIsTransparentColumn.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_file" ADD "isTransparent" boolean NOT NULL DEFAULT false;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset_file" DROP COLUMN "isTransparent";`.execute(db); +} diff --git a/server/src/schema/migrations/1771873044511-ChangesTokensToBuffers.ts b/server/src/schema/migrations/1771873044511-ChangesTokensToBuffers.ts new file mode 100644 index 0000000000..b0ed28a55c --- /dev/null +++ b/server/src/schema/migrations/1771873044511-ChangesTokensToBuffers.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "api_key" ALTER COLUMN "key" TYPE bytea USING decode("key", 'base64');`.execute(db); + await sql`ALTER TABLE "session" ALTER COLUMN "token" TYPE bytea USING decode("token", 'base64');`.execute(db); + await sql`CREATE INDEX "api_key_key_idx" ON "api_key" ("key");`.execute(db); + await sql`CREATE INDEX "session_token_idx" ON "session" ("token");`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "api_key_key_idx";`.execute(db); + await sql`DROP INDEX "session_token_idx";`.execute(db); + await sql`ALTER TABLE "api_key" ALTER COLUMN "key" TYPE character varying USING encode("key", 'base64');`.execute(db); + await sql`ALTER TABLE "session" ALTER COLUMN "token" TYPE character varying USING encode("token", 'base64');`.execute(db); +} diff --git a/server/src/schema/migrations/1771873813973-AssetEditSync.ts b/server/src/schema/migrations/1771873813973-AssetEditSync.ts new file mode 100644 index 0000000000..4f5be1ddcd --- /dev/null +++ b/server/src/schema/migrations/1771873813973-AssetEditSync.ts @@ -0,0 +1,53 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_edit_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_edit_audit ("editId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_edit_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "editId" uuid NOT NULL, + "assetId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "asset_edit_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "asset_edit_audit_assetId_idx" ON "asset_edit_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_edit_audit_deletedAt_idx" ON "asset_edit_audit" ("deletedAt");`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`CREATE INDEX "asset_edit_updateId_idx" ON "asset_edit" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_audit" + AFTER DELETE ON "asset_edit" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_edit_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_updatedAt" + BEFORE UPDATE ON "asset_edit" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_edit_audit', '{"type":"function","name":"asset_edit_audit","sql":"CREATE OR REPLACE FUNCTION asset_edit_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_edit_audit (\\"editId\\", \\"assetId\\")\\n SELECT \\"id\\", \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_audit', '{"type":"trigger","name":"asset_edit_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_audit\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_updatedAt', '{"type":"trigger","name":"asset_edit_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_edit\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_edit_audit" ON "asset_edit";`.execute(db); + await sql`DROP TRIGGER "asset_edit_updatedAt" ON "asset_edit";`.execute(db); + await sql`DROP INDEX "asset_edit_updateId_idx";`.execute(db); + await sql`ALTER TABLE "asset_edit" DROP COLUMN "updatedAt";`.execute(db); + await sql`ALTER TABLE "asset_edit" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TABLE "asset_edit_audit";`.execute(db); + await sql`DROP FUNCTION asset_edit_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_edit_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_updatedAt';`.execute(db); +} diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index dfa7c98e42..4a3cc196ee 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -1,8 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { Check, Column, @@ -15,7 +10,12 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('activity') @UpdatedAtTrigger('activity_updatedAt') diff --git a/server/src/schema/tables/album-asset-audit.table.ts b/server/src/schema/tables/album-asset-audit.table.ts index ab8fd9ae89..176d32575a 100644 --- a/server/src/schema/tables/album-asset-audit.table.ts +++ b/server/src/schema/tables/album-asset-audit.table.ts @@ -1,6 +1,6 @@ +import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; import { AlbumTable } from 'src/schema/tables/album.table'; -import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('album_asset_audit') export class AlbumAssetAuditTable { diff --git a/server/src/schema/tables/album-asset.table.ts b/server/src/schema/tables/album-asset.table.ts index dea271239b..5853e846f1 100644 --- a/server/src/schema/tables/album-asset.table.ts +++ b/server/src/schema/tables/album-asset.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { album_asset_delete_audit } from 'src/schema/functions'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { AfterDeleteTrigger, CreateDateColumn, @@ -10,7 +6,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { album_asset_delete_audit } from 'src/schema/functions'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; @Table({ name: 'album_asset' }) @UpdatedAtTrigger('album_asset_updatedAt') diff --git a/server/src/schema/tables/album-audit.table.ts b/server/src/schema/tables/album-audit.table.ts index 432c51c36a..7865f6bfa8 100644 --- a/server/src/schema/tables/album-audit.table.ts +++ b/server/src/schema/tables/album-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('album_audit') export class AlbumAuditTable { diff --git a/server/src/schema/tables/album-user-audit.table.ts b/server/src/schema/tables/album-user-audit.table.ts index 2259511bdd..d4798761e0 100644 --- a/server/src/schema/tables/album-user-audit.table.ts +++ b/server/src/schema/tables/album-user-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('album_user_audit') export class AlbumUserAuditTable { diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 761aabc1af..2e38041daf 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -1,8 +1,3 @@ -import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AlbumUserRole } from 'src/enum'; -import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, AfterInsertTrigger, @@ -13,7 +8,12 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AlbumUserRole } from 'src/enum'; +import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table({ name: 'album_user' }) // Pre-existing indices from original album <--> user ManyToMany mapping diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index 5628db3d03..81b846c0f4 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -1,8 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetOrder } from 'src/enum'; -import { album_delete_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -14,7 +9,12 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetOrder } from 'src/enum'; +import { album_delete_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table({ name: 'album' }) @UpdatedAtTrigger('album_updatedAt') diff --git a/server/src/schema/tables/api-key.table.ts b/server/src/schema/tables/api-key.table.ts index efbf18afaa..f0e33a9c71 100644 --- a/server/src/schema/tables/api-key.table.ts +++ b/server/src/schema/tables/api-key.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { Permission } from 'src/enum'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -10,7 +7,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { Permission } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('api_key') @UpdatedAtTrigger('api_key_updatedAt') @@ -21,8 +21,8 @@ export class ApiKeyTable { @Column() name!: string; - @Column() - key!: string; + @Column({ type: 'bytea', index: true }) + key!: Buffer; @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) userId!: string; diff --git a/server/src/schema/tables/asset-audit.table.ts b/server/src/schema/tables/asset-audit.table.ts index 86c3f6f28b..fee6dde59a 100644 --- a/server/src/schema/tables/asset-audit.table.ts +++ b/server/src/schema/tables/asset-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_audit') export class AssetAuditTable { diff --git a/server/src/schema/tables/asset-edit-audit.table.ts b/server/src/schema/tables/asset-edit-audit.table.ts new file mode 100644 index 0000000000..9c8b29f374 --- /dev/null +++ b/server/src/schema/tables/asset-edit-audit.table.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; + +@Table('asset_edit_audit') +export class AssetEditAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid' }) + editId!: string; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index 886b62dc0b..2e9d2be20d 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -1,6 +1,3 @@ -import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; -import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { AfterDeleteTrigger, AfterInsertTrigger, @@ -9,10 +6,17 @@ import { Generated, PrimaryGeneratedColumn, Table, + Timestamp, Unique, -} from 'src/sql-tools'; + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto'; +import { asset_edit_audit, asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_edit') +@UpdatedAtTrigger('asset_edit_updatedAt') @AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' }) @AfterDeleteTrigger({ scope: 'statement', @@ -20,8 +24,14 @@ import { referencingOldTableAs: 'deleted_edit', when: 'pg_trigger_depth() = 0', }) +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_edit_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) @Unique({ columns: ['assetId', 'sequence'] }) -export class AssetEditTable { +export class AssetEditTable { @PrimaryGeneratedColumn() id!: Generated; @@ -29,11 +39,17 @@ export class AssetEditTable { assetId!: string; @Column() - action!: T; + action!: AssetEditAction; @Column({ type: 'jsonb' }) - parameters!: AssetEditActionParameter[T]; + parameters!: AssetEditParameters; @Column({ type: 'integer' }) sequence!: number; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; } diff --git a/server/src/schema/tables/asset-exif.table.ts b/server/src/schema/tables/asset-exif.table.ts index 9dacb547cf..1ae8f731a9 100644 --- a/server/src/schema/tables/asset-exif.table.ts +++ b/server/src/schema/tables/asset-exif.table.ts @@ -1,7 +1,7 @@ +import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools'; import { LockableProperty } from 'src/database'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools'; @Table('asset_exif') @UpdatedAtTrigger('asset_exif_updatedAt') diff --git a/server/src/schema/tables/asset-face-audit.table.ts b/server/src/schema/tables/asset-face-audit.table.ts index 4f03c22aa0..2e61904800 100644 --- a/server/src/schema/tables/asset-face-audit.table.ts +++ b/server/src/schema/tables/asset-face-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_face_audit') export class AssetFaceAuditTable { diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 8b156f2a17..b67e5e5dac 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,9 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { SourceType } from 'src/enum'; -import { asset_face_source_type } from 'src/schema/enums'; -import { asset_face_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { PersonTable } from 'src/schema/tables/person.table'; import { AfterDeleteTrigger, Column, @@ -15,7 +9,13 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { SourceType } from 'src/enum'; +import { asset_face_source_type } from 'src/schema/enums'; +import { asset_face_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { PersonTable } from 'src/schema/tables/person.table'; @Table({ name: 'asset_face' }) @UpdatedAtTrigger('asset_face_updatedAt') @@ -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-file.table.ts b/server/src/schema/tables/asset-file.table.ts index 73b5171a47..6285e4d653 100644 --- a/server/src/schema/tables/asset-file.table.ts +++ b/server/src/schema/tables/asset-file.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetFileType } from 'src/enum'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { Column, CreateDateColumn, @@ -11,7 +8,10 @@ import { Timestamp, Unique, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetFileType } from 'src/enum'; +import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_file') @Unique({ columns: ['assetId', 'type', 'isEdited'] }) @@ -43,4 +43,7 @@ export class AssetFileTable { @Column({ type: 'boolean', default: false }) isProgressive!: Generated; + + @Column({ type: 'boolean', default: false }) + isTransparent!: Generated; } diff --git a/server/src/schema/tables/asset-job-status.table.ts b/server/src/schema/tables/asset-job-status.table.ts index 62194825e5..4d889ade46 100644 --- a/server/src/schema/tables/asset-job-status.table.ts +++ b/server/src/schema/tables/asset-job-status.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Table, Timestamp } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Table, Timestamp } from 'src/sql-tools'; @Table('asset_job_status') export class AssetJobStatusTable { diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts index 16272eacf7..15c0b47edc 100644 --- a/server/src/schema/tables/asset-metadata-audit.table.ts +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_metadata_audit') export class AssetMetadataAuditTable { diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index 8a7af1360f..53e3121a41 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetMetadataKey } from 'src/enum'; -import { asset_metadata_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { AfterDeleteTrigger, Column, @@ -11,7 +7,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetMetadataKey } from 'src/enum'; +import { asset_metadata_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; @UpdatedAtTrigger('asset_metadata_updated_at') @Table('asset_metadata') diff --git a/server/src/schema/tables/asset-ocr.table.ts b/server/src/schema/tables/asset-ocr.table.ts index b9b0838cbe..b58224a247 100644 --- a/server/src/schema/tables/asset-ocr.table.ts +++ b/server/src/schema/tables/asset-ocr.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; @Table('asset_ocr') export class AssetOcrTable { diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 0b3da710ac..12e9c36125 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -1,10 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; -import { asset_delete_audit } from 'src/schema/functions'; -import { LibraryTable } from 'src/schema/tables/library.table'; -import { StackTable } from 'src/schema/tables/stack.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -17,7 +10,14 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; +import { asset_delete_audit } from 'src/schema/functions'; +import { LibraryTable } from 'src/schema/tables/library.table'; +import { StackTable } from 'src/schema/tables/stack.table'; +import { UserTable } from 'src/schema/tables/user.table'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; @Table('asset') @@ -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/schema/tables/audit.table.ts b/server/src/schema/tables/audit.table.ts index 15b4990814..78c9a57c09 100644 --- a/server/src/schema/tables/audit.table.ts +++ b/server/src/schema/tables/audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools'; import { DatabaseAction, EntityType } from 'src/enum'; -import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools'; @Table('audit') @Index({ columns: ['ownerId', 'createdAt'] }) diff --git a/server/src/schema/tables/face-search.table.ts b/server/src/schema/tables/face-search.table.ts index ff63879404..7c585437c8 100644 --- a/server/src/schema/tables/face-search.table.ts +++ b/server/src/schema/tables/face-search.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'face_search' }) @Index({ diff --git a/server/src/schema/tables/geodata-places.table.ts b/server/src/schema/tables/geodata-places.table.ts index eec2b240d0..101ddb759f 100644 --- a/server/src/schema/tables/geodata-places.table.ts +++ b/server/src/schema/tables/geodata-places.table.ts @@ -1,4 +1,4 @@ -import { Column, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools'; +import { Column, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools'; @Table({ name: 'geodata_places', primaryConstraintName: 'geodata_places_pkey' }) @Index({ diff --git a/server/src/schema/tables/library.table.ts b/server/src/schema/tables/library.table.ts index 57ad144c8e..2f79a3e78d 100644 --- a/server/src/schema/tables/library.table.ts +++ b/server/src/schema/tables/library.table.ts @@ -1,5 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -10,7 +8,9 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('library') @UpdatedAtTrigger('library_updatedAt') diff --git a/server/src/schema/tables/memory-asset-audit.table.ts b/server/src/schema/tables/memory-asset-audit.table.ts index 218c2f19ff..67c434c45a 100644 --- a/server/src/schema/tables/memory-asset-audit.table.ts +++ b/server/src/schema/tables/memory-asset-audit.table.ts @@ -1,6 +1,6 @@ +import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; import { MemoryTable } from 'src/schema/tables/memory.table'; -import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('memory_asset_audit') export class MemoryAssetAuditTable { diff --git a/server/src/schema/tables/memory-asset.table.ts b/server/src/schema/tables/memory-asset.table.ts index b162000ca0..b44c78c3b9 100644 --- a/server/src/schema/tables/memory-asset.table.ts +++ b/server/src/schema/tables/memory-asset.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { memory_asset_delete_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { MemoryTable } from 'src/schema/tables/memory.table'; import { AfterDeleteTrigger, CreateDateColumn, @@ -10,7 +6,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { memory_asset_delete_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { MemoryTable } from 'src/schema/tables/memory.table'; @Table('memory_asset') @UpdatedAtTrigger('memory_asset_updatedAt') diff --git a/server/src/schema/tables/memory-audit.table.ts b/server/src/schema/tables/memory-audit.table.ts index 167caf8e6e..6d278676b7 100644 --- a/server/src/schema/tables/memory-audit.table.ts +++ b/server/src/schema/tables/memory-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('memory_audit') export class MemoryAuditTable { diff --git a/server/src/schema/tables/memory.table.ts b/server/src/schema/tables/memory.table.ts index 408f7bca19..8b9867b4cc 100644 --- a/server/src/schema/tables/memory.table.ts +++ b/server/src/schema/tables/memory.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { MemoryType } from 'src/enum'; -import { memory_delete_audit } from 'src/schema/functions'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -13,7 +9,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { MemoryType } from 'src/enum'; +import { memory_delete_audit } from 'src/schema/functions'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('memory') @UpdatedAtTrigger('memory_updatedAt') diff --git a/server/src/schema/tables/move.table.ts b/server/src/schema/tables/move.table.ts index 1afda2767a..c7229431f7 100644 --- a/server/src/schema/tables/move.table.ts +++ b/server/src/schema/tables/move.table.ts @@ -1,5 +1,5 @@ +import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools'; import { PathType } from 'src/enum'; -import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from 'src/sql-tools'; @Table('move_history') // path lock (per entity) diff --git a/server/src/schema/tables/natural-earth-countries.table.ts b/server/src/schema/tables/natural-earth-countries.table.ts index c59d15fc21..06f189264e 100644 --- a/server/src/schema/tables/natural-earth-countries.table.ts +++ b/server/src/schema/tables/natural-earth-countries.table.ts @@ -1,4 +1,4 @@ -import { Column, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { Column, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; @Table({ name: 'naturalearth_countries', primaryConstraintName: 'naturalearth_countries_pkey' }) export class NaturalEarthCountriesTable { diff --git a/server/src/schema/tables/notification.table.ts b/server/src/schema/tables/notification.table.ts index 01a93a73e5..6bf65808f1 100644 --- a/server/src/schema/tables/notification.table.ts +++ b/server/src/schema/tables/notification.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { NotificationLevel, NotificationType } from 'src/enum'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -11,7 +8,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('notification') @UpdatedAtTrigger('notification_updatedAt') diff --git a/server/src/schema/tables/ocr-search.table.ts b/server/src/schema/tables/ocr-search.table.ts index 3449725adb..74aefb333b 100644 --- a/server/src/schema/tables/ocr-search.table.ts +++ b/server/src/schema/tables/ocr-search.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table('ocr_search') @Index({ diff --git a/server/src/schema/tables/partner-audit.table.ts b/server/src/schema/tables/partner-audit.table.ts index fa2f0c27cc..3cfd1854e1 100644 --- a/server/src/schema/tables/partner-audit.table.ts +++ b/server/src/schema/tables/partner-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('partner_audit') export class PartnerAuditTable { diff --git a/server/src/schema/tables/partner.table.ts b/server/src/schema/tables/partner.table.ts index 8fc332cb12..408cac650f 100644 --- a/server/src/schema/tables/partner.table.ts +++ b/server/src/schema/tables/partner.table.ts @@ -1,6 +1,3 @@ -import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { partner_delete_audit } from 'src/schema/functions'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -10,7 +7,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { partner_delete_audit } from 'src/schema/functions'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('partner') @UpdatedAtTrigger('partner_updatedAt') diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts index 8a899a1808..4fb55f1744 100644 --- a/server/src/schema/tables/person-audit.table.ts +++ b/server/src/schema/tables/person-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('person_audit') export class PersonAuditTable { diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 3b523a39d2..02fb85b757 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { person_delete_audit } from 'src/schema/functions'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Check, @@ -13,7 +9,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { person_delete_audit } from 'src/schema/functions'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('person') @UpdatedAtTrigger('person_updatedAt') diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts index 3de7ca63c9..5f82807f23 100644 --- a/server/src/schema/tables/plugin.table.ts +++ b/server/src/schema/tables/plugin.table.ts @@ -1,4 +1,3 @@ -import { PluginContext } from 'src/enum'; import { Column, CreateDateColumn, @@ -9,7 +8,8 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { PluginContext } from 'src/enum'; import type { JSONSchema } from 'src/types/plugin-schema.types'; @Table('plugin') diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 466152d35d..e57628d6da 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -1,5 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -9,7 +7,9 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserTable } from 'src/schema/tables/user.table'; @Table({ name: 'session' }) @UpdatedAtTrigger('session_updatedAt') @@ -17,9 +17,8 @@ export class SessionTable { @PrimaryGeneratedColumn() id!: Generated; - // TODO convert to byte[] - @Column() - token!: string; + @Column({ type: 'bytea', index: true }) + token!: Buffer; @CreateDateColumn() createdAt!: Generated; diff --git a/server/src/schema/tables/shared-link-asset.table.ts b/server/src/schema/tables/shared-link-asset.table.ts index 37e6a3d9f0..ff96f69980 100644 --- a/server/src/schema/tables/shared-link-asset.table.ts +++ b/server/src/schema/tables/shared-link-asset.table.ts @@ -1,6 +1,6 @@ +import { ForeignKeyColumn, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; -import { ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('shared_link_asset') export class SharedLinkAssetTable { diff --git a/server/src/schema/tables/shared-link.table.ts b/server/src/schema/tables/shared-link.table.ts index 80e2d7cdf4..d99520388a 100644 --- a/server/src/schema/tables/shared-link.table.ts +++ b/server/src/schema/tables/shared-link.table.ts @@ -1,6 +1,3 @@ -import { SharedLinkType } from 'src/enum'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -9,7 +6,10 @@ import { PrimaryGeneratedColumn, Table, Timestamp, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { SharedLinkType } from 'src/enum'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('shared_link') export class SharedLinkTable { diff --git a/server/src/schema/tables/smart-search.table.ts b/server/src/schema/tables/smart-search.table.ts index dc140efb2f..31071e6134 100644 --- a/server/src/schema/tables/smart-search.table.ts +++ b/server/src/schema/tables/smart-search.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'smart_search' }) @Index({ diff --git a/server/src/schema/tables/stack-audit.table.ts b/server/src/schema/tables/stack-audit.table.ts index d46ff95e57..3a62545cd2 100644 --- a/server/src/schema/tables/stack-audit.table.ts +++ b/server/src/schema/tables/stack-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('stack_audit') export class StackAuditTable { diff --git a/server/src/schema/tables/stack.table.ts b/server/src/schema/tables/stack.table.ts index 9c9eb81373..3f903e065a 100644 --- a/server/src/schema/tables/stack.table.ts +++ b/server/src/schema/tables/stack.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { stack_delete_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, CreateDateColumn, @@ -11,7 +7,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { stack_delete_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('stack') @UpdatedAtTrigger('stack_updatedAt') diff --git a/server/src/schema/tables/sync-checkpoint.table.ts b/server/src/schema/tables/sync-checkpoint.table.ts index 6ad4c54a86..d9ada5aed0 100644 --- a/server/src/schema/tables/sync-checkpoint.table.ts +++ b/server/src/schema/tables/sync-checkpoint.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { SyncEntityType } from 'src/enum'; -import { SessionTable } from 'src/schema/tables/session.table'; import { Column, CreateDateColumn, @@ -10,7 +7,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { SyncEntityType } from 'src/enum'; +import { SessionTable } from 'src/schema/tables/session.table'; @Table('session_sync_checkpoint') @UpdatedAtTrigger('session_sync_checkpoint_updatedAt') diff --git a/server/src/schema/tables/system-metadata.table.ts b/server/src/schema/tables/system-metadata.table.ts index 8657768db6..9f21172505 100644 --- a/server/src/schema/tables/system-metadata.table.ts +++ b/server/src/schema/tables/system-metadata.table.ts @@ -1,5 +1,5 @@ +import { Column, PrimaryColumn, Table } from '@immich/sql-tools'; import { SystemMetadataKey } from 'src/enum'; -import { Column, PrimaryColumn, Table } from 'src/sql-tools'; import { SystemMetadata } from 'src/types'; @Table('system_metadata') diff --git a/server/src/schema/tables/tag-asset.table.ts b/server/src/schema/tables/tag-asset.table.ts index 3ea2361b4f..9d7ea026c6 100644 --- a/server/src/schema/tables/tag-asset.table.ts +++ b/server/src/schema/tables/tag-asset.table.ts @@ -1,6 +1,6 @@ +import { ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; import { TagTable } from 'src/schema/tables/tag.table'; -import { ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Index({ columns: ['assetId', 'tagId'] }) @Table('tag_asset') diff --git a/server/src/schema/tables/tag-closure.table.ts b/server/src/schema/tables/tag-closure.table.ts index aeb8c8cf11..2e1c83a20f 100644 --- a/server/src/schema/tables/tag-closure.table.ts +++ b/server/src/schema/tables/tag-closure.table.ts @@ -1,5 +1,5 @@ +import { ForeignKeyColumn, Table } from '@immich/sql-tools'; import { TagTable } from 'src/schema/tables/tag.table'; -import { ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('tag_closure') export class TagClosureTable { diff --git a/server/src/schema/tables/tag.table.ts b/server/src/schema/tables/tag.table.ts index dc1fa2947b..2a07239d84 100644 --- a/server/src/schema/tables/tag.table.ts +++ b/server/src/schema/tables/tag.table.ts @@ -1,5 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -10,7 +8,9 @@ import { Timestamp, Unique, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('tag') @UpdatedAtTrigger('tag_updatedAt') diff --git a/server/src/schema/tables/user-audit.table.ts b/server/src/schema/tables/user-audit.table.ts index 084b42fb65..36f89dfa7d 100644 --- a/server/src/schema/tables/user-audit.table.ts +++ b/server/src/schema/tables/user-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('user_audit') export class UserAuditTable { diff --git a/server/src/schema/tables/user-metadata-audit.table.ts b/server/src/schema/tables/user-metadata-audit.table.ts index 63f503ab85..17dee673b4 100644 --- a/server/src/schema/tables/user-metadata-audit.table.ts +++ b/server/src/schema/tables/user-metadata-audit.table.ts @@ -1,6 +1,6 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; import { UserMetadataKey } from 'src/enum'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('user_metadata_audit') export class UserMetadataAuditTable { diff --git a/server/src/schema/tables/user-metadata.table.ts b/server/src/schema/tables/user-metadata.table.ts index a453ec6677..6983ed3dda 100644 --- a/server/src/schema/tables/user-metadata.table.ts +++ b/server/src/schema/tables/user-metadata.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserMetadataKey } from 'src/enum'; -import { user_metadata_audit } from 'src/schema/functions'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -11,7 +7,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserMetadataKey } from 'src/enum'; +import { user_metadata_audit } from 'src/schema/functions'; +import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; @UpdatedAtTrigger('user_metadata_updated_at') diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 46d6656382..3a340d976b 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -1,7 +1,3 @@ -import { ColumnType } from 'kysely'; -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserAvatarColor, UserStatus } from 'src/enum'; -import { user_delete_audit } from 'src/schema/functions'; import { AfterDeleteTrigger, Column, @@ -13,7 +9,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { ColumnType } from 'kysely'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserAvatarColor, UserStatus } from 'src/enum'; +import { user_delete_audit } from 'src/schema/functions'; @Table('user') @UpdatedAtTrigger('user_updatedAt') diff --git a/server/src/schema/tables/version-history.table.ts b/server/src/schema/tables/version-history.table.ts index 143852c527..12eab7fd69 100644 --- a/server/src/schema/tables/version-history.table.ts +++ b/server/src/schema/tables/version-history.table.ts @@ -1,4 +1,4 @@ -import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from 'src/sql-tools'; +import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools'; @Table('version_history') export class VersionHistoryTable { diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 62a5531d8e..163518e039 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -1,6 +1,3 @@ -import { PluginTriggerType } from 'src/enum'; -import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -10,7 +7,10 @@ import { PrimaryGeneratedColumn, Table, Timestamp, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { PluginTriggerType } from 'src/enum'; +import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UserTable } from 'src/schema/tables/user.table'; import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; @Table('workflow') diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 14544f454f..3a31dbbea1 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -24,7 +24,7 @@ describe(ApiKeyService.name, () => { await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions }); expect(mocks.apiKey.create).toHaveBeenCalledWith({ - key: 'super-secret (hashed)', + key: Buffer.from('super-secret (hashed)'), name: apiKey.name, permissions: apiKey.permissions, userId: apiKey.userId, @@ -44,7 +44,7 @@ describe(ApiKeyService.name, () => { await sut.create(auth, { permissions: [Permission.All] }); expect(mocks.apiKey.create).toHaveBeenCalledWith({ - key: 'super-secret (hashed)', + key: Buffer.from('super-secret (hashed)'), name: 'API Key', permissions: [Permission.All], userId: auth.user.id, diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 492ee9c0fd..534de69107 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -10,14 +10,14 @@ import { isGranted } from 'src/utils/access'; export class ApiKeyService extends BaseService { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise { const token = this.cryptoRepository.randomBytesAsText(32); - const tokenHashed = this.cryptoRepository.hashSha256(token); + const hashed = this.cryptoRepository.hashSha256(token); if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { throw new BadRequestException('Cannot grant permissions you do not have'); } const entity = await this.apiKeyRepository.create({ - key: tokenHashed, + key: hashed, name: dto.name || 'API Key', userId: auth.user.id, permissions: dto.permissions, diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 84440fd4b6..5fb45690cf 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -110,6 +110,7 @@ const validVideos = [ '.mp4', '.mpg', '.mts', + '.mxf', '.vob', '.webm', '.wmv', diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index b677881cfe..db895f8321 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -660,7 +660,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([ocr1, ocr2]); @@ -676,7 +676,7 @@ describe(AssetService.name, () => { const asset = AssetFactory.from().exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([]); expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ed427684f1..f41004dd1c 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -21,7 +21,7 @@ import { mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetEditAction, AssetEditActionCrop, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; +import { AssetEditAction, AssetEditActionItem, AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { AssetFileType, @@ -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 { @@ -539,19 +543,20 @@ export class AssetService extends BaseService { } } - async getAssetEdits(auth: AuthDto, id: string): Promise { + async getAssetEdits(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const edits = await this.assetEditRepository.getAll(id); + return { assetId: id, edits, }; } - async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise { + async editAsset(auth: AuthDto, id: string, dto: AssetEditsCreateDto): 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 +581,22 @@ export class AssetService extends BaseService { throw new BadRequestException('Editing SVG images is not supported'); } - const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop); - if (cropIndex > 0) { - throw new BadRequestException('Crop action must be the first edit action'); + // 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 crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop); + const edits = dto.edits as AssetEditActionItem[]; + const crop = edits.find((e) => e.action === AssetEditAction.Crop); if (crop) { + if (edits[0].action !== AssetEditAction.Crop) { + throw new BadRequestException('Crop action must be the first edit action'); + } + // 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'); @@ -596,7 +608,7 @@ export class AssetService extends BaseService { } } - const newEdits = await this.assetEditRepository.replaceAll(id, dto.edits); + const newEdits = await this.assetEditRepository.replaceAll(id, edits); await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } }); // Return the asset and its applied edits diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index a34efedfb0..81f601da0a 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -513,7 +513,7 @@ describe(AuthService.name, () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).rejects.toBeInstanceOf(UnauthorizedException); - expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith(Buffer.from('auth_token (hashed)')); }); it('should throw an error if api key has insufficient permissions', async () => { @@ -574,7 +574,7 @@ describe(AuthService.name, () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) }); - expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith(Buffer.from('auth_token (hashed)')); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index a6580f89dd..b8e1b78107 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -456,8 +456,8 @@ export class AuthService extends BaseService { } private async validateApiKey(key: string): Promise { - const hashedKey = this.cryptoRepository.hashSha256(key); - const apiKey = await this.apiKeyRepository.getKey(hashedKey); + const hashed = this.cryptoRepository.hashSha256(key); + const apiKey = await this.apiKeyRepository.getKey(hashed); if (apiKey?.user) { return { user: apiKey.user, @@ -476,9 +476,9 @@ export class AuthService extends BaseService { return this.cryptoRepository.compareBcrypt(inputSecret, existingHash); } - private async validateSession(tokenValue: string, headers: IncomingHttpHeaders): Promise { - const hashedToken = this.cryptoRepository.hashSha256(tokenValue); - const session = await this.sessionRepository.getByToken(hashedToken); + private async validateSession(token: string, headers: IncomingHttpHeaders): Promise { + const hashed = this.cryptoRepository.hashSha256(token); + const session = await this.sessionRepository.getByToken(hashed); if (session?.user) { const { appVersion, deviceOS, deviceType } = getUserAgentDetails(headers); const now = DateTime.now(); @@ -543,10 +543,10 @@ export class AuthService extends BaseService { private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const token = this.cryptoRepository.randomBytesAsText(32); - const tokenHashed = this.cryptoRepository.hashSha256(token); + const hashed = this.cryptoRepository.hashSha256(token); await this.sessionRepository.create({ - token: tokenHashed, + token: hashed, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, appVersion: loginDetails.appVersion, diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 479fd130a6..22f06e2ed9 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,3 +1,4 @@ +import { schemaDiff } from '@immich/sql-tools'; import { Injectable } from '@nestjs/common'; import { isAbsolute, join } from 'node:path'; import { SALT_ROUNDS } from 'src/constants'; @@ -5,7 +6,6 @@ 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'; 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/download.service.spec.ts b/server/src/services/download.service.spec.ts index ae010623d8..1ae1b0b4d8 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -39,7 +39,7 @@ describe(DownloadService.name, () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id, 'unknown-asset'])); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id, 'unknown-asset'] })).resolves.toEqual({ @@ -62,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({ @@ -86,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,7 +108,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); 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({ @@ -130,7 +130,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset2, asset1]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -151,7 +151,7 @@ describe(DownloadService.name, () => { const asset = AssetFactory.create({ originalPath: '/path/to/symlink.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); mocks.storage.createZipStream.mockReturnValue(archiveMock); 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/job.service.ts b/server/src/services/job.service.ts index 2a47745a6c..7c9581ff9a 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -98,6 +98,7 @@ export class JobService extends BaseService { case JobName.AssetEditThumbnailGeneration: { const asset = await this.assetRepository.getById(item.data.id); + const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id); if (asset) { this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { @@ -122,6 +123,7 @@ export class JobService extends BaseService { height: asset.height, isEdited: asset.isEdited, }, + edit: edits, }); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index bf2cbc62fa..fc825fb273 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -21,12 +21,13 @@ 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 { faceStub } from 'test/fixtures/face.stub'; +import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; -import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; +import { personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory } from 'test/small.factory'; +import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const fullsizeBuffer = Buffer.from('embedded image data'); @@ -50,9 +51,10 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { const asset = AssetFactory.create(); + const person = PersonFactory.create({ faceAssetId: newUuid() }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); + mocks.person.getAll.mockReturnValue(makeStream([person])); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -68,7 +70,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: personStub.newThumbnail.id }, + data: { id: person.id }, }, ]); }); @@ -106,9 +108,14 @@ describe(MediaService.name, () => { }); it('should queue all people with missing thumbnail path', async () => { + const [person1, person2] = [ + PersonFactory.create({ thumbnailPath: undefined }), + PersonFactory.create({ thumbnailPath: undefined }), + ]; + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); - mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); - mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); + mocks.person.getAll.mockReturnValue(makeStream([person1, person2])); + mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -120,7 +127,7 @@ describe(MediaService.name, () => { { name: JobName.PersonGenerateThumbnail, data: { - id: personStub.newThumbnail.id, + id: person1.id, }, }, ]); @@ -276,17 +283,17 @@ describe(MediaService.name, () => { describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { const asset = AssetFactory.create(); + const person = PersonFactory.create(); + mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); + mocks.person.getAll.mockReturnValue(makeStream([person])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success); expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); 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 } }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]); }); }); @@ -348,6 +355,7 @@ describe(MediaService.name, () => { : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); + mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false }); }); it('should skip thumbnail generation if asset not found', async () => { @@ -467,6 +475,7 @@ describe(MediaService.name, () => { path: expect.any(String), isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: asset.id, @@ -474,6 +483,7 @@ describe(MediaService.name, () => { path: expect.any(String), isEdited: false, isProgressive: false, + isTransparent: false, }, ]); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, thumbhash: thumbhashBuffer }); @@ -508,6 +518,7 @@ describe(MediaService.name, () => { path: expect.any(String), isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: asset.id, @@ -515,6 +526,7 @@ describe(MediaService.name, () => { path: expect.any(String), isEdited: false, isProgressive: false, + isTransparent: false, }, ]); }); @@ -548,6 +560,7 @@ describe(MediaService.name, () => { path: expect.any(String), isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: asset.id, @@ -555,6 +568,7 @@ describe(MediaService.name, () => { path: expect.any(String), isEdited: false, isProgressive: false, + isTransparent: false, }, ]); }); @@ -770,10 +784,12 @@ describe(MediaService.name, () => { expect.objectContaining({ type: AssetFileType.Preview, isProgressive: true, + isTransparent: false, }), expect.objectContaining({ type: AssetFileType.Thumbnail, isProgressive: false, + isTransparent: false, }), ]); }); @@ -807,10 +823,12 @@ describe(MediaService.name, () => { expect.objectContaining({ type: AssetFileType.Preview, isProgressive: false, + isTransparent: false, }), expect.objectContaining({ type: AssetFileType.Thumbnail, isProgressive: true, + isTransparent: false, }), ]); }); @@ -829,10 +847,12 @@ describe(MediaService.name, () => { expect.objectContaining({ type: AssetFileType.Preview, isProgressive: false, + isTransparent: false, }), expect.objectContaining({ type: AssetFileType.Thumbnail, isProgressive: false, + isTransparent: false, }), ]); }); @@ -857,7 +877,7 @@ describe(MediaService.name, () => { .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.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); @@ -871,12 +891,39 @@ describe(MediaService.name, () => { }); }); + it('should not check transparency metadata for raw files without extracted images', 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(asset); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.media.getImageMetadata).not.toHaveBeenCalled(); + }); + + it('should not check transparency metadata for raw files with extracted images', 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.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.media.getImageMetadata).toHaveBeenCalledOnce(); + expect(mocks.media.getImageMetadata).toHaveBeenCalledWith(extractedBuffer); + }); + 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.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); @@ -970,7 +1017,7 @@ describe(MediaService.name, () => { 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.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1008,7 +1055,7 @@ describe(MediaService.name, () => { 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.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1056,7 +1103,7 @@ describe(MediaService.name, () => { 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.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1100,7 +1147,7 @@ describe(MediaService.name, () => { it('should generate full-size preview from non-web-friendly images', async () => { 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.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ @@ -1139,7 +1186,7 @@ describe(MediaService.name, () => { 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.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1162,7 +1209,7 @@ describe(MediaService.name, () => { it('should always generate full-size preview from non-web-friendly panoramas', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.media.copyTagGroup.mockResolvedValue(true); const asset = AssetFactory.from({ originalFileName: 'panorama.tif' }) @@ -1208,7 +1255,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ @@ -1248,7 +1295,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ fileSizeInByte: 5000, @@ -1286,6 +1333,7 @@ describe(MediaService.name, () => { : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); + mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false }); }); it('should skip videos', async () => { @@ -1438,8 +1486,9 @@ describe(MediaService.name, () => { }); it('should skip a person without a face asset id', async () => { - mocks.person.getById.mockResolvedValue(personStub.noThumbnail); - await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); + const person = PersonFactory.create({ faceAssetId: null }); + mocks.person.getById.mockResolvedValue(person); + await sut.handleGeneratePersonThumbnail({ id: person.id }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); @@ -1449,17 +1498,17 @@ describe(MediaService.name, () => { }); it('should generate a thumbnail', async () => { + const person = PersonFactory.create(); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); + expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, { colorspace: Colorspace.P3, @@ -1490,21 +1539,21 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); }); it('should use preview path if video', async () => { + const person = PersonFactory.create(); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); + expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), { colorspace: Colorspace.P3, @@ -1535,19 +1584,19 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); }); it('should generate a thumbnail without going negative', async () => { + const person = PersonFactory.create(); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, { colorspace: Colorspace.P3, @@ -1581,16 +1630,16 @@ describe(MediaService.name, () => { }); it('should generate a thumbnail without overflowing', async () => { + const person = PersonFactory.create(); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd); - mocks.person.update.mockResolvedValue(personStub.primaryPerson); + mocks.person.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, { colorspace: Colorspace.P3, @@ -1624,16 +1673,16 @@ describe(MediaService.name, () => { }); it('should handle negative coordinates', async () => { + const person = PersonFactory.create(); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate); - mocks.person.update.mockResolvedValue(personStub.primaryPerson); + mocks.person.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1667,16 +1716,16 @@ describe(MediaService.name, () => { }); it('should handle overflowing coordinate', async () => { + const person = PersonFactory.create(); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate); - mocks.person.update.mockResolvedValue(personStub.primaryPerson); + mocks.person.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1710,20 +1759,20 @@ describe(MediaService.name, () => { }); it('should use embedded preview if enabled and raw image', async () => { + const person = PersonFactory.create(); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); - mocks.person.update.mockResolvedValue(personStub.primaryPerson); + mocks.person.update.mockResolvedValue(person); mocks.media.generateThumbnail.mockResolvedValue(); const extracted = Buffer.from(''); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.decodeImage.mockResolvedValue({ data, info }); - mocks.media.getImageDimensions.mockResolvedValue(info); + mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, { @@ -1758,21 +1807,23 @@ describe(MediaService.name, () => { }); it('should not use embedded preview if enabled and not raw image', async () => { + const person = PersonFactory.create(); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).toHaveBeenCalled(); }); it('should not use embedded preview if enabled and raw image if not exists', async () => { + const person = PersonFactory.create(); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); @@ -1780,9 +1831,7 @@ describe(MediaService.name, () => { const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { @@ -1794,6 +1843,8 @@ describe(MediaService.name, () => { }); it('should not use embedded preview if enabled and raw image if low resolution', async () => { + const person = PersonFactory.create(); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); @@ -1802,11 +1853,9 @@ describe(MediaService.name, () => { const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue(info); + mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.Success, - ); + await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { @@ -3554,6 +3603,7 @@ describe(MediaService.name, () => { path: '/new/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: asset.id, @@ -3561,6 +3611,7 @@ describe(MediaService.name, () => { path: '/new/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ]); @@ -3571,6 +3622,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: 'asset-id', @@ -3578,6 +3630,7 @@ describe(MediaService.name, () => { type: AssetFileType.Thumbnail, isEdited: false, isProgressive: false, + isTransparent: false, }, ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); @@ -3595,6 +3648,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { id: 'file-2', @@ -3603,6 +3657,7 @@ describe(MediaService.name, () => { path: '/old/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ], }; @@ -3614,6 +3669,7 @@ describe(MediaService.name, () => { path: '/new/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: asset.id, @@ -3621,6 +3677,7 @@ describe(MediaService.name, () => { path: '/new/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ]); @@ -3631,6 +3688,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: 'asset-id', @@ -3638,6 +3696,7 @@ describe(MediaService.name, () => { type: AssetFileType.Thumbnail, isEdited: false, isProgressive: false, + isTransparent: false, }, ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); @@ -3658,6 +3717,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { id: 'file-2', @@ -3666,6 +3726,7 @@ describe(MediaService.name, () => { path: '/old/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ], }; @@ -3681,6 +3742,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { id: 'file-2', @@ -3689,6 +3751,7 @@ describe(MediaService.name, () => { path: '/old/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -3708,6 +3771,7 @@ describe(MediaService.name, () => { path: '/same/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { id: 'file-2', @@ -3716,6 +3780,7 @@ describe(MediaService.name, () => { path: '/same/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ], }; @@ -3727,6 +3792,7 @@ describe(MediaService.name, () => { path: '/same/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: asset.id, @@ -3734,6 +3800,7 @@ describe(MediaService.name, () => { path: '/same/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ]); @@ -3753,6 +3820,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { id: 'file-2', @@ -3761,6 +3829,7 @@ describe(MediaService.name, () => { path: '/old/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ], }; @@ -3772,6 +3841,7 @@ describe(MediaService.name, () => { path: '/new/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, // replace { assetId: asset.id, @@ -3779,6 +3849,7 @@ describe(MediaService.name, () => { path: '/new/fullsize.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, // new ]); @@ -3789,6 +3860,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, isEdited: false, isProgressive: false, + isTransparent: false, }, { assetId: 'asset-id', @@ -3796,6 +3868,7 @@ describe(MediaService.name, () => { type: AssetFileType.FullSize, isEdited: false, isProgressive: false, + isTransparent: false, }, ]); expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ @@ -3806,6 +3879,7 @@ describe(MediaService.name, () => { path: '/old/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -3838,6 +3912,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ], }; @@ -3853,6 +3928,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -3872,6 +3948,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, { id: 'file-2', @@ -3880,6 +3957,7 @@ describe(MediaService.name, () => { path: '/old/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ], }; @@ -3891,6 +3969,7 @@ describe(MediaService.name, () => { path: '/old/preview.jpg', isEdited: false, isProgressive: true, + isTransparent: false, }, { assetId: asset.id, @@ -3898,6 +3977,7 @@ describe(MediaService.name, () => { path: '/old/thumbnail.jpg', isEdited: false, isProgressive: false, + isTransparent: false, }, ]); @@ -3908,6 +3988,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, isEdited: false, isProgressive: true, + isTransparent: false, }, ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5fa72cf117..3555d7d108 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -52,6 +52,7 @@ interface UpsertFileOptions { path: string; isEdited: boolean; isProgressive: boolean; + isTransparent: boolean; } type ThumbnailAsset = NonNullable>>; @@ -280,14 +281,20 @@ export class MediaService extends BaseService { useEdits; const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); + const thumbSource = extracted ? extracted.buffer : asset.originalPath; const { data, info, colorspace } = await this.decodeImage( - extracted ? extracted.buffer : asset.originalPath, + thumbSource, // only specify orientation to extracted images which don't have EXIF orientation data // or it can double rotate the image extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null }, convertFullsize ? undefined : image.preview.size, ); + let isTransparent = false; + if (!extracted && mimeTypes.canBeTransparent(asset.originalPath)) { + ({ isTransparent } = await this.mediaRepository.getImageMetadata(asset.originalPath)); + } + return { extracted, data, @@ -295,50 +302,64 @@ export class MediaService extends BaseService { colorspace, convertFullsize, generateFullsize, + isTransparent, }; } private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) { + // Handle embedded preview extraction for RAW files + const extractedImage = await this.extractOriginalImage(asset, image, useEdits); + const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } = extractedImage; + + const previewFormat = image.preview.format; + this.warnOnTransparencyLoss(isTransparent, previewFormat, asset.id); + + const thumbnailFormat = image.thumbnail.format; + this.warnOnTransparencyLoss(isTransparent, thumbnailFormat, asset.id); + const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, - format: image.preview.format, + format: previewFormat, isEdited: useEdits, - isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp, + isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp, + isTransparent, }); const thumbnailFile = this.getImageFile(asset, { fileType: AssetFileType.Thumbnail, - format: image.thumbnail.format, + format: thumbnailFormat, isEdited: useEdits, - isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp, + isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp, + isTransparent, }); this.storageCore.ensureFolders(previewFile.path); - // Handle embedded preview extraction for RAW files - const extractedImage = await this.extractOriginalImage(asset, image, useEdits); - const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage; - // generate final images - const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; + const baseOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; + const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat }; + const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat }; const promises = [ - this.mediaRepository.generateThumbhash(data, thumbnailOptions), - this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path), - this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path), + this.mediaRepository.generateThumbhash(data, baseOptions), + this.mediaRepository.generateThumbnail(data, thumbnailOptions, thumbnailFile.path), + this.mediaRepository.generateThumbnail(data, previewOptions, previewFile.path), ]; let fullsizeFile: UpsertFileOptions | undefined; if (convertFullsize) { + const fullsizeFormat = image.fullsize.format; + this.warnOnTransparencyLoss(isTransparent, fullsizeFormat, asset.id); // convert a new fullsize image from the same source as the thumbnail fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, - format: image.fullsize.format, + format: fullsizeFormat, isEdited: useEdits, - isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, + isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp, + isTransparent, }); const fullsizeOptions = { - format: image.fullsize.format, + ...baseOptions, + format: fullsizeFormat, quality: image.fullsize.quality, progressive: image.fullsize.progressive, - ...thumbnailOptions, }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path)); } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { @@ -347,6 +368,7 @@ export class MediaService extends BaseService { format: extracted.format, isEdited: false, isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, + isTransparent, }); this.storageCore.ensureFolders(fullsizeFile.path); @@ -493,12 +515,14 @@ export class MediaService extends BaseService { format: image.preview.format, isEdited: false, isProgressive: false, + isTransparent: false, }); const thumbnailFile = this.getImageFile(asset, { fileType: AssetFileType.Thumbnail, format: image.thumbnail.format, isEdited: false, isProgressive: false, + isTransparent: false, }); this.storageCore.ensureFolders(previewFile.path); @@ -758,7 +782,7 @@ export class MediaService extends BaseService { } private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) { - const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer); + const { width, height } = await this.mediaRepository.getImageMetadata(extractedPathOrBuffer); const extractedSize = Math.min(width, height); return extractedSize >= targetSize; } @@ -785,7 +809,10 @@ export class MediaService extends BaseService { } } - private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) { + private async syncFiles( + oldFiles: (AssetFile & { isProgressive: boolean; isTransparent: boolean })[], + newFiles: UpsertFileOptions[], + ) { const toUpsert: UpsertFileOptions[] = []; const pathsToDelete: string[] = []; const toDelete = new Set(oldFiles); @@ -797,7 +824,11 @@ export class MediaService extends BaseService { } // upsert new file path - if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) { + if ( + existingFile?.path !== newFile.path || + existingFile.isProgressive !== newFile.isProgressive || + existingFile.isTransparent !== newFile.isTransparent + ) { toUpsert.push(newFile); // delete old file from disk @@ -857,7 +888,18 @@ export class MediaService extends BaseService { return generated; } - private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) { + private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) { + if (isTransparent && format === ImageFormat.Jpeg) { + this.logger.warn( + `Asset ${assetId} has transparency but the configured format is ${format} which does not support it, consider using a format that does, such as ${ImageFormat.Webp}`, + ); + } + } + + private getImageFile( + asset: ThumbnailPathEntity, + options: ImagePathOptions & { isProgressive: boolean; isTransparent: boolean }, + ) { const path = StorageCore.getImagePath(asset, options); return { assetId: asset.id, @@ -865,6 +907,7 @@ export class MediaService extends BaseService { path, isEdited: options.isEdited, isProgressive: options.isProgressive, + isTransparent: options.isTransparent, }; } } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index db682b6393..2378d594e1 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -100,6 +100,8 @@ export class MemoryService extends BaseService { data: dto.data, isSaved: dto.isSaved, memoryAt: dto.memoryAt, + showAt: dto.showAt, + hideAt: dto.hideAt, seenAt: dto.seenAt, }, allowedAssetIds, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8530f6fed2..feaba36b1d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -16,8 +16,8 @@ import { import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { AssetFactory } from 'test/factories/asset.factory'; +import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; -import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -295,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, @@ -382,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); @@ -396,11 +394,9 @@ 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); @@ -420,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); @@ -434,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); @@ -448,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); @@ -463,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); @@ -485,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); @@ -508,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); @@ -537,11 +521,9 @@ 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); @@ -554,7 +536,7 @@ describe(MetadataService.name, () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => { const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) }); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -568,11 +550,9 @@ describe(MetadataService.name, () => { }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - const baseAsset = AssetFactory.from(); - const asset = baseAsset.build(); - const updatedAsset = baseAsset.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }).build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(updatedAsset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -919,7 +899,7 @@ describe(MetadataService.name, () => { Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: 'UTC-11:30', + zone: 'UTC-11:30', TagsList: ['parent/child'], Rating: 3, }; @@ -955,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, @@ -987,7 +967,7 @@ describe(MetadataService.name, () => { const tags: ImmichTags = { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), - tz: undefined, + zone: undefined, }; mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); @@ -1228,18 +1208,18 @@ describe(MetadataService.name, () => { it('should apply metadata face tags creating new people', async () => { const asset = AssetFactory.create(); + const person = PersonFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: personStub.withName.name })); + mockReadTags(makeFaceTags({ Name: person.name })); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([personStub.withName.id]); - mocks.person.update.mockResolvedValue(personStub.withName); + mocks.person.createAll.mockResolvedValue([person.id]); + mocks.person.update.mockResolvedValue(person); 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 }), - ]); + expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { @@ -1263,19 +1243,21 @@ describe(MetadataService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: personStub.withName.id }, + data: { id: person.id }, }, ]); }); it('should assign metadata face tags to existing persons', async () => { const asset = AssetFactory.create(); + const person = PersonFactory.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 }]); + mockReadTags(makeFaceTags({ Name: person.name })); + mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]); mocks.person.createAll.mockResolvedValue([]); - mocks.person.update.mockResolvedValue(personStub.withName); + mocks.person.update.mockResolvedValue(person); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); @@ -1285,7 +1267,7 @@ describe(MetadataService.name, () => { { id: 'random-uuid', assetId: asset.id, - personId: personStub.withName.id, + personId: person.id, imageHeight: 100, imageWidth: 1000, boundingBoxX1: 0, @@ -1355,21 +1337,20 @@ describe(MetadataService.name, () => { async ({ orientation, expected }) => { const { imgW, imgH, x1, x2, y1, y2 } = expected; const asset = AssetFactory.create(); + const person = PersonFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation)); + mockReadTags(makeFaceTags({ Name: person.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([personStub.withName.id]); - mocks.person.update.mockResolvedValue(personStub.withName); + mocks.person.createAll.mockResolvedValue([person.id]); + mocks.person.update.mockResolvedValue(person); 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 }), - ]); + expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { @@ -1393,7 +1374,7 @@ describe(MetadataService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: personStub.withName.id }, + data: { id: person.id }, }, ]); }, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4113025914..c5d7278d56 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; } @@ -162,7 +169,7 @@ export class MetadataService extends BaseService { this.logger.log(`Initialized local reverse geocoder`); } catch (error: Error | any) { this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack); - throw new Error(`Metadata service init failed`); + throw new Error('Metadata service init failed', { cause: error }); } } @@ -279,11 +286,11 @@ export class MetadataService extends BaseService { orientation: validate(exifTags.Orientation)?.toString() ?? null, projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, bitsPerSample: this.getBitsPerSample(exifTags), - colorspace: exifTags.ColorSpace ?? null, + colorspace: exifTags.ColorSpace === undefined ? null : String(exifTags.ColorSpace), // 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-admin.service.ts b/server/src/services/notification-admin.service.ts index bf0d2bba41..2fc4584dca 100644 --- a/server/src/services/notification-admin.service.ts +++ b/server/src/services/notification-admin.service.ts @@ -59,7 +59,7 @@ export class NotificationAdminService extends BaseService { async getTemplate(name: EmailTemplate, customTemplate: string) { const { server, templates } = await this.getConfig({ withCache: false }); - let templateResponse = ''; + let templateResponse: string; switch (name) { case EmailTemplate.WELCOME: { diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index ee87fcf775..9f11d19af7 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -134,7 +134,7 @@ export class NotificationService extends BaseService { } } catch (error: Error | any) { this.logger.error(`Failed to validate SMTP configuration: ${error}`, error?.stack); - throw new Error(`Invalid SMTP configuration: ${error}`); + throw new Error('Invalid SMTP configuration', { cause: error }); } } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 0928b57f97..c22fd65a1a 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,61 +1,21 @@ 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 { mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; -import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; +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 = { - id: 'person-1', - name: 'Person 1', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - isHidden: false, - updatedAt: expect.any(Date), - isFavorite: false, - color: expect.any(String), -}; - -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; @@ -70,60 +30,54 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all hidden and visible people with thumbnails', async () => { + const auth = AuthFactory.create(); + const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })]; + mocks.person.getAllForUser.mockResolvedValue({ - items: [personStub.withName, personStub.hidden], + items: [person, hiddenPerson], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); - await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ + await expect(sut.getAll(auth, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, hidden: 1, people: [ - responseDto, - { - id: 'person-1', - name: '', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', + expect.objectContaining({ id: person.id, isHidden: false }), + expect.objectContaining({ + id: hiddenPerson.id, isHidden: true, - isFavorite: false, - updatedAt: expect.any(Date), - color: expect.any(String), - }, + }), ], }); - expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { minimumFaceCount: 3, withHidden: true, }); }); it('should get all visible people and favorites should be first in the array', async () => { + const auth = AuthFactory.create(); + const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()]; + mocks.person.getAllForUser.mockResolvedValue({ - items: [personStub.isFavorite, personStub.withName], + items: [isFavorite, person], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); - await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ + await expect(sut.getAll(auth, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, hidden: 1, people: [ - { - id: 'person-4', - name: personStub.isFavorite.name, - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - isHidden: false, + expect.objectContaining({ + id: isFavorite.id, isFavorite: true, - updatedAt: expect.any(Date), - color: personStub.isFavorite.color, - }, - responseDto, + }), + expect.objectContaining({ id: person.id, isFavorite: false }), ], }); - expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { minimumFaceCount: 3, withHidden: false, }); @@ -132,71 +86,89 @@ describe(PersonService.name, () => { describe('getById', () => { it('should require person.read permission', async () => { - mocks.person.getById.mockResolvedValue(personStub.withName); - await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + mocks.person.getById.mockResolvedValue(person); + await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it('should throw a bad request when person is not found', async () => { - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + const auth = AuthFactory.create(); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown'])); + await expect(sut.getById(auth, 'unknown')).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown'])); }); it('should get a person by id', async () => { - mocks.person.getById.mockResolvedValue(personStub.withName); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); - expect(mocks.person.getById).toHaveBeenCalledWith('person-1'); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + + mocks.person.getById.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id })); + expect(mocks.person.getById).toHaveBeenCalledWith(person.id); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); }); describe('getThumbnail', () => { it('should require person.read permission', async () => { - mocks.person.getById.mockResolvedValue(personStub.noName); - await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + + mocks.person.getById.mockResolvedValue(person); + await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - 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.id])); }); it('should throw an error when personId is invalid', async () => { - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); + const auth = AuthFactory.create(); + + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown'])); + await expect(sut.getThumbnail(auth, 'unknown')).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - 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(['unknown'])); }); it('should throw an error when person has no thumbnail', async () => { - mocks.person.getById.mockResolvedValue(personStub.noThumbnail); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); + const auth = AuthFactory.create(); + const person = PersonFactory.create({ thumbnailPath: '' }); + + mocks.person.getById.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - 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.id])); }); it('should serve the thumbnail', async () => { - mocks.person.getById.mockResolvedValue(personStub.noName); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual( + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + + mocks.person.getById.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual( new ImmichFileResponse({ - path: '/path/to/thumbnail.jpg', + path: person.thumbnailPath, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithoutCache, }), ); - 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.id])); }); }); describe('update', () => { it('should require person.write permission', async () => { - mocks.person.getById.mockResolvedValue(personStub.noName); - await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( - BadRequestException, - ); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + + mocks.person.getById.mockResolvedValue(person); + await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException); expect(mocks.person.update).not.toHaveBeenCalled(); - 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.id])); }); it('should throw an error when personId is invalid', async () => { @@ -209,88 +181,108 @@ describe(PersonService.name, () => { }); it("should update a person's name", async () => { - mocks.person.update.mockResolvedValue(personStub.withName); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create({ name: 'Person 1' }); - await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); + mocks.person.update.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual( + expect.objectContaining({ id: person.id, name: 'Person 1' }), + ); + + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it("should update a person's date of birth", async () => { - mocks.person.update.mockResolvedValue(personStub.withBirthDate); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create({ birthDate: new Date('1976-06-30') }); - await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ - id: 'person-1', - name: 'Person 1', + mocks.person.update.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + + await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({ + id: person.id, + name: person.name, birthDate: '1976-06-30', - thumbnailPath: '/path/to/thumbnail.jpg', + thumbnailPath: person.thumbnailPath, isHidden: false, isFavorite: false, updatedAt: expect.any(Date), - color: expect.any(String), }); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it('should update a person visibility', async () => { - mocks.person.update.mockResolvedValue(personStub.withName); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create({ isHidden: true }); - await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); + mocks.person.update.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual( + expect.objectContaining({ isHidden: true }), + ); + + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it('should update a person favorite status', async () => { - mocks.person.update.mockResolvedValue(personStub.withName); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create({ isFavorite: true }); - await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto); + mocks.person.update.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual( + expect.objectContaining({ isFavorite: true }), + ); + + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it("should update a person's thumbnailPath", async () => { - mocks.person.update.mockResolvedValue(personStub.withName); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); - await expect( - sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), - ).resolves.toEqual(responseDto); + mocks.person.update.mockResolvedValue(person); + mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); - expect(mocks.person.getFacesByIds).toHaveBeenCalledWith([ - { - assetId: faceStub.face1.assetId, - personId: 'person-1', - }, - ]); + await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual( + expect.objectContaining({ id: person.id }), + ); + + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id }); + expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ + assetId: face.assetId, + personId: person.id, + }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonGenerateThumbnail, - data: { id: 'person-1' }, + data: { id: person.id }, }); - 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.id])); }); it('should throw an error when the face feature assetId is invalid', async () => { - mocks.person.getById.mockResolvedValue(personStub.withName); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); - await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( - BadRequestException, - ); + mocks.person.getById.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + + await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException); expect(mocks.person.update).not.toHaveBeenCalled(); - 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.id])); }); }); @@ -311,34 +303,39 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect( - sut.reassignFaces(authStub.admin, personStub.noName.id, { + sut.reassignFaces(AuthFactory.create(), 'person-id', { data: [{ personId: 'asset-face-1', assetId: '' }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); + it('should reassign a face', async () => { - 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]); + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getById.mockResolvedValue(person); + 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); + mocks.person.update.mockResolvedValue(person); await expect( - sut.reassignFaces(authStub.admin, personStub.noName.id, { - data: [{ personId: personStub.withName.id, assetId: faceStub.face1.assetId }], + sut.reassignFaces(auth, person.id, { + data: [{ personId: person.id, assetId: face.assetId }], }), ).resolves.toBeDefined(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: personStub.newThumbnail.id }, + data: { id: person.id }, }, ]); }); @@ -346,24 +343,26 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.Failed); + await expect(sut.handlePersonMigration(PersonFactory.create())).resolves.toBe(JobStatus.Failed); }); }); describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - const asset = AssetFactory.from({ id: faceStub.face1.assetId }).exif().build(); + 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([faceStub.primaryFace1]); - mocks.asset.getById.mockResolvedValue(asset); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ - mapFaces(faceStub.primaryFace1, authStub.admin), - ]); + 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, ); }); @@ -371,12 +370,14 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); - await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); + const person = PersonFactory.create(); + + mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); + await sut.createNewFeaturePhoto([person.id]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: personStub.newThumbnail.id }, + data: { id: person.id }, }, ]); }); @@ -384,24 +385,22 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { - 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); + const face = AssetFaceFactory.create(); + const person = PersonFactory.create(); + + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + 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, + mocks.person.getById.mockResolvedValue(person); + await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({ + birthDate: person.birthDate, + isHidden: person.isHidden, + isFavorite: person.isFavorite, + id: person.id, + name: person.name, + thumbnailPath: person.thumbnailPath, updatedAt: expect.any(Date), - color: personStub.noName.color, }); expect(mocks.job.queue).not.toHaveBeenCalledWith(); @@ -409,13 +408,16 @@ describe(PersonService.name, () => { }); it('should fail if user has not the correct permissions on the asset', async () => { - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + const face = AssetFaceFactory.create(); + const person = PersonFactory.create(); + + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.person.getById.mockResolvedValue(person); await expect( - sut.reassignFacesById(authStub.admin, personStub.noName.id, { - id: faceStub.face1.id, + sut.reassignFacesById(AuthFactory.create(), person.id, { + id: face.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -426,22 +428,25 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { - mocks.person.create.mockResolvedValue(personStub.primaryPerson); + const auth = AuthFactory.create(); - await expect(sut.create(authStub.admin, {})).resolves.toBeDefined(); + mocks.person.create.mockResolvedValue(PersonFactory.create()); + await expect(sut.create(auth, {})).resolves.toBeDefined(); - expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id }); }); }); describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { - mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]); + const person = PersonFactory.create(); + + mocks.person.getAllWithoutFaces.mockResolvedValue([person]); await sut.handlePersonCleanup(); - expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]); - expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); }); }); @@ -473,15 +478,17 @@ describe(PersonService.name, () => { it('should queue all assets', async () => { const asset = AssetFactory.create(); + const person = PersonFactory.create(); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); - mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); + mocks.person.getAllWithoutFaces.mockResolvedValue([person]); await sut.handleQueueDetectFaces({ force: true }); expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning }); - expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); - expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -513,10 +520,13 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { const asset = AssetFactory.create(); - mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + const face = AssetFaceFactory.from().person().build(); + const person = PersonFactory.create(); + + mocks.person.getAll.mockReturnValue(makeStream([face.person!, person])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); - mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.person.getAllWithoutFaces.mockResolvedValue([person]); mocks.person.deleteFaces.mockResolvedValue(); await sut.handleQueueDetectFaces({ force: true }); @@ -528,8 +538,8 @@ describe(PersonService.name, () => { data: { id: asset.id }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); - expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); }); }); @@ -568,6 +578,7 @@ describe(PersonService.name, () => { }); it('should queue missing assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -576,7 +587,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({}); @@ -588,7 +599,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, { @@ -598,6 +609,7 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -607,7 +619,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 }); @@ -616,7 +628,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, { @@ -626,8 +638,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, @@ -637,7 +650,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(); @@ -652,7 +665,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, { @@ -666,7 +679,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 }); @@ -680,6 +693,9 @@ describe(PersonService.name, () => { }); it('should delete existing people if forced', async () => { + const face = AssetFaceFactory.from().person().build(); + const person = PersonFactory.create(); + mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -688,9 +704,9 @@ 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.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, person])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); + mocks.person.getAllWithoutFaces.mockResolvedValue([person]); mocks.person.unassignFaces.mockResolvedValue(); await sut.handleQueueRecognizeFaces({ force: true }); @@ -700,20 +716,16 @@ 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]); - expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); }); describe('handleDetectFaces', () => { - beforeEach(() => { - mocks.crypto.randomUUID.mockReturnValue(faceId); - }); - it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -753,85 +765,104 @@ describe(PersonService.name, () => { it('should create a face with no person and queue recognition job', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); + 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: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [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(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); 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 () => { - const asset = AssetFactory.from().face(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + 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: asset.id }); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( - [{ ...face, assetId: asset.id }], - [faceStub.primaryFace1.id], - [faceSearch], + [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 () => { - const asset = AssetFactory.from().face(faceStub.fromExif1).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + 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: 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 () => { - const asset = AssetFactory.from().face(faceStub.fromExif2).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + 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: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [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 +871,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 +1045,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 +1070,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); @@ -1038,59 +1094,71 @@ describe(PersonService.name, () => { describe('mergePerson', () => { it('should require person.write and person.merge permission', async () => { - mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); - mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + const auth = AuthFactory.create(); + const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( + mocks.person.getById.mockResolvedValueOnce(person); + mocks.person.getById.mockResolvedValueOnce(mergePerson); + + await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf( BadRequestException, ); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it('should merge two people without smart merge', async () => { - mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); - mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + const auth = AuthFactory.create(); + const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ - { id: 'person-2', success: true }, + mocks.person.getById.mockResolvedValueOnce(person); + mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); + + await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ + { id: mergePerson.id, success: true }, ]); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: personStub.primaryPerson.id, - oldPersonId: personStub.mergePerson.id, + newPersonId: person.id, + oldPersonId: mergePerson.id, }); - 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.id])); }); it('should merge two people with smart merge', async () => { - mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson); - mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); - mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + const auth = AuthFactory.create(); + const [person, mergePerson] = [ + PersonFactory.create({ name: undefined }), + PersonFactory.create({ name: 'Merge person' }), + ]; - await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([ - { id: 'person-1', success: true }, + mocks.person.getById.mockResolvedValueOnce(person); + mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name }); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); + + await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ + { id: mergePerson.id, success: true }, ]); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: personStub.randomPerson.id, - oldPersonId: personStub.primaryPerson.id, + newPersonId: person.id, + oldPersonId: mergePerson.id, }); expect(mocks.person.update).toHaveBeenCalledWith({ - id: personStub.randomPerson.id, - name: personStub.primaryPerson.name, + id: person.id, + name: mergePerson.name, }); - 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.id])); }); it('should throw an error when the primary person is not found', async () => { @@ -1105,73 +1173,89 @@ describe(PersonService.name, () => { }); it('should handle invalid merge ids', async () => { - mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); - await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ - { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, + mocks.person.getById.mockResolvedValueOnce(person); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown'])); + + await expect(sut.mergePerson(auth, person.id, { ids: ['unknown'] })).resolves.toEqual([ + { id: 'unknown', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it('should handle an error reassigning faces', async () => { - mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); - mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); - mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + const auth = AuthFactory.create(); + const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ - { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, + mocks.person.getById.mockResolvedValueOnce(person); + mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); + + await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ + { id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN }, ]); expect(mocks.person.delete).not.toHaveBeenCalled(); - 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.id])); }); }); describe('getStatistics', () => { it('should get correct number of person', async () => { - mocks.person.getById.mockResolvedValue(personStub.primaryPerson); - mocks.person.getStatistics.mockResolvedValue(statistics); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + + mocks.person.getById.mockResolvedValue(person); + mocks.person.getStatistics.mockResolvedValue({ assets: 3 }); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it('should require person.read permission', async () => { - mocks.person.getById.mockResolvedValue(personStub.primaryPerson); - await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + const auth = AuthFactory.create(); + const person = PersonFactory.create(); + + mocks.person.getById.mockResolvedValue(person); + await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); }); 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..8a902590e3 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; @@ -599,7 +595,7 @@ export class PersonService extends BaseService { update.birthDate = mergePerson.birthDate; } - if (Object.keys(update).length > 0) { + if (Object.keys(update).length > 1) { primaryPerson = await this.personRepository.update(update); } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 5f1125eaed..62575d0f07 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -5,7 +5,6 @@ import { SearchService } from 'src/services/search.service'; 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'; import { beforeEach, vitest } from 'vitest'; @@ -26,17 +25,18 @@ describe(SearchService.name, () => { describe('searchPerson', () => { it('should pass options to search', async () => { - const { name } = personStub.withName; + const auth = AuthFactory.create(); + const name = 'foo'; mocks.person.getByName.mockResolvedValue([]); - await sut.searchPerson(authStub.user1, { name, withHidden: false }); + await sut.searchPerson(auth, { name, withHidden: false }); - expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); + expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: false }); - await sut.searchPerson(authStub.user1, { name, withHidden: true }); + await sut.searchPerson(auth, { name, withHidden: true }); - expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); + expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: true }); }); }); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 2f477c0d6a..8b5bd13928 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -33,14 +33,14 @@ export class SessionService extends BaseService { } const token = this.cryptoRepository.randomBytesAsText(32); - const tokenHashed = this.cryptoRepository.hashSha256(token); + const hashed = this.cryptoRepository.hashSha256(token); const session = await this.sessionRepository.create({ parentId: auth.session.id, userId: auth.user.id, expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, deviceType: dto.deviceType, deviceOS: dto.deviceOS, - token: tokenHashed, + token: hashed, }); return { ...mapSession(session), token }; diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 5ad145af2b..07f31db4da 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -69,8 +69,9 @@ describe(SharedLinkService.name, () => { it('should accept a valid shared link auth token', async () => { mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); - mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token'); - await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined(); + const secret = Buffer.from('auth-token-123'); + mocks.crypto.hashSha256.mockReturnValue(secret); + await expect(sut.getMine(authStub.adminSharedLink, [secret.toString('base64')])).resolves.toBeDefined(); expect(mocks.sharedLink.get).toHaveBeenCalledWith( authStub.adminSharedLink.user.id, authStub.adminSharedLink.sharedLink?.id, diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index e321e4990d..b942c32326 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -236,6 +236,6 @@ export class SharedLinkService extends BaseService { } private asToken(sharedLink: { id: string; password: string }) { - return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`); + return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`).toString('base64'); } } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index d5020a9c5e..a8f4e6a185 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -125,7 +125,7 @@ export class StorageTemplateService extends BaseService { }); } catch (error) { this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`); - throw new Error(`Invalid storage template: ${error}`); + throw new Error('Invalid storage template', { cause: error }); } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index f354a71791..9bdeca14d7 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -12,6 +12,7 @@ import { AssetFullSyncDto, SyncAckDeleteDto, SyncAckSetDto, + syncAssetFaceV2ToV1, SyncAssetV1, SyncItem, SyncStreamDto, @@ -85,8 +86,10 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.MemoryToAssetsV1, SyncRequestType.PeopleV1, SyncRequestType.AssetFacesV1, + SyncRequestType.AssetFacesV2, SyncRequestType.UserMetadataV1, SyncRequestType.AssetMetadataV1, + SyncRequestType.AssetEditsV1, ]; const throwSessionRequired = () => { @@ -173,6 +176,7 @@ export class SyncService extends BaseService { [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap), [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), + [SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap), [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id), [SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth), [SyncRequestType.PartnerAssetExifsV1]: () => @@ -189,6 +193,7 @@ export class SyncService extends BaseService { [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap), + [SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), }; @@ -212,6 +217,7 @@ export class SyncService extends BaseService { await this.syncRepository.asset.cleanupAuditTable(pruneThreshold); await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold); await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold); + await this.syncRepository.assetEdit.cleanupAuditTable(pruneThreshold); await this.syncRepository.memory.cleanupAuditTable(pruneThreshold); await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold); await this.syncRepository.partner.cleanupAuditTable(pruneThreshold); @@ -349,6 +355,21 @@ export class SyncService extends BaseService { } } + private async syncAssetEditsV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.AssetEditDeleteV1; + const deletes = this.syncRepository.assetEdit.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + const upsertType = SyncEntityType.AssetEditV1; + const upserts = this.syncRepository.assetEdit.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncPartnerAssetExifsV1( options: SyncQueryOptions, response: Writable, @@ -789,6 +810,21 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.AssetFaceV1; const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + const v1 = syncAssetFaceV2ToV1(data); + send(response, { type: upsertType, ids: [updateId], data: v1 }); + } + } + + private async syncAssetFacesV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.AssetFaceDeleteV1; + const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetFaceV2; + const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { send(response, { type: upsertType, ids: [updateId], data }); } 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/sql-tools/comparers/column.comparer.spec.ts b/server/src/sql-tools/comparers/column.comparer.spec.ts deleted file mode 100644 index ef2afb348a..0000000000 --- a/server/src/sql-tools/comparers/column.comparer.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { compareColumns } from 'src/sql-tools/comparers/column.comparer'; -import { DatabaseColumn, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testColumn: DatabaseColumn = { - name: 'test', - tableName: 'table1', - primary: false, - nullable: false, - isArray: false, - type: 'character varying', - synchronize: true, -}; - -describe('compareColumns', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareColumns().onExtra(testColumn)).toEqual([ - { - tableName: 'table1', - columnName: 'test', - type: 'ColumnDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareColumns().onMissing(testColumn)).toEqual([ - { - type: 'ColumnAdd', - column: testColumn, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - 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([ - { - columnName: 'test', - tableName: 'table1', - type: 'ColumnDrop', - reason, - }, - { - type: 'ColumnAdd', - column: source, - reason, - }, - ]); - }); - - it('should detect a change in default', () => { - 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([ - { - columnName: 'test', - tableName: 'table1', - type: 'ColumnAlter', - changes: { - default: 'NULL', - }, - reason, - }, - ]); - }); - - it('should detect a comment change', () => { - 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([ - { - columnName: 'test', - tableName: 'table1', - type: 'ColumnAlter', - changes: { - comment: 'new comment', - }, - reason, - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/column.comparer.ts b/server/src/sql-tools/comparers/column.comparer.ts deleted file mode 100644 index 54ffb34ffa..0000000000 --- a/server/src/sql-tools/comparers/column.comparer.ts +++ /dev/null @@ -1,108 +0,0 @@ -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, - }, - ], - 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; - }, - }) satisfies Comparer; - -const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { - return [ - { - type: 'ColumnDrop', - tableName: target.tableName, - columnName: target.name, - reason, - }, - { type: 'ColumnAdd', column: source, reason }, - ]; -}; diff --git a/server/src/sql-tools/comparers/constraint.comparer.spec.ts b/server/src/sql-tools/comparers/constraint.comparer.spec.ts deleted file mode 100644 index 216728f8c4..0000000000 --- a/server/src/sql-tools/comparers/constraint.comparer.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { compareConstraints } from 'src/sql-tools/comparers/constraint.comparer'; -import { ConstraintType, DatabaseConstraint, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testConstraint: DatabaseConstraint = { - type: ConstraintType.PRIMARY_KEY, - name: 'test', - tableName: 'table1', - columnNames: ['column1'], - synchronize: true, -}; - -describe('compareConstraints', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareConstraints().onExtra(testConstraint)).toEqual([ - { - type: 'ConstraintDrop', - constraintName: 'test', - tableName: 'table1', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareConstraints().onMissing(testConstraint)).toEqual([ - { - type: 'ConstraintAdd', - constraint: testConstraint, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - 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([ - { - constraintName: 'test', - tableName: 'table1', - type: 'ConstraintDrop', - reason, - }, - { - type: 'ConstraintAdd', - constraint: source, - reason, - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/constraint.comparer.ts b/server/src/sql-tools/comparers/constraint.comparer.ts deleted file mode 100644 index 03128878d5..0000000000 --- a/server/src/sql-tools/comparers/constraint.comparer.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { asRenameKey, haveEqualColumns } from 'src/sql-tools/helpers'; -import { - CompareFunction, - Comparer, - ConstraintType, - DatabaseCheckConstraint, - DatabaseConstraint, - DatabaseForeignKeyConstraint, - DatabasePrimaryKeyConstraint, - DatabaseUniqueConstraint, - Reason, - SchemaDiff, -} from 'src/sql-tools/types'; - -export const compareConstraints = (): Comparer => ({ - getRenameKey: (constraint) => { - switch (constraint.type) { - case ConstraintType.PRIMARY_KEY: - case ConstraintType.UNIQUE: { - return asRenameKey([constraint.type, constraint.tableName, ...constraint.columnNames.toSorted()]); - } - - case ConstraintType.FOREIGN_KEY: { - return asRenameKey([ - constraint.type, - constraint.tableName, - ...constraint.columnNames.toSorted(), - constraint.referenceTableName, - ...constraint.referenceColumnNames.toSorted(), - ]); - } - - case ConstraintType.CHECK: { - const expression = constraint.expression.replaceAll('(', '').replaceAll(')', ''); - return asRenameKey([constraint.type, constraint.tableName, expression]); - } - } - }, - onRename: (source, target) => [ - { - type: 'ConstraintRename', - tableName: target.tableName, - oldName: target.name, - newName: source.name, - reason: Reason.Rename, - }, - ], - onMissing: (source) => [ - { - type: 'ConstraintAdd', - constraint: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'ConstraintDrop', - tableName: target.tableName, - constraintName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - switch (source.type) { - case ConstraintType.PRIMARY_KEY: { - return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); - } - - case ConstraintType.FOREIGN_KEY: { - return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); - } - - case ConstraintType.UNIQUE: { - return compareUniqueConstraint(source, target as DatabaseUniqueConstraint); - } - - case ConstraintType.CHECK: { - return compareCheckConstraint(source, target as DatabaseCheckConstraint); - } - - default: { - return []; - } - } - }, -}); - -const comparePrimaryKeyConstraint: CompareFunction = (source, target) => { - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - return dropAndRecreateConstraint( - source, - target, - `Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`, - ); - } - - return []; -}; - -const compareForeignKeyConstraint: CompareFunction = (source, target) => { - let reason = ''; - - const sourceDeleteAction = source.onDelete ?? 'NO ACTION'; - const targetDeleteAction = target.onDelete ?? 'NO ACTION'; - - const sourceUpdateAction = source.onUpdate ?? 'NO ACTION'; - const targetUpdateAction = target.onUpdate ?? 'NO ACTION'; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) { - reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`; - } else if (source.referenceTableName !== target.referenceTableName) { - reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`; - } else if (sourceDeleteAction !== targetDeleteAction) { - reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`; - } else if (sourceUpdateAction !== targetUpdateAction) { - reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const compareUniqueConstraint: CompareFunction = (source, target) => { - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const compareCheckConstraint: CompareFunction = (source, target) => { - if (source.expression !== target.expression) { - // comparing expressions is hard because postgres reconstructs it with different formatting - // for now if the constraint exists with the same name, we will just skip it - } - - return []; -}; - -const dropAndRecreateConstraint = ( - source: DatabaseConstraint, - target: DatabaseConstraint, - reason: string, -): SchemaDiff[] => { - return [ - { - type: 'ConstraintDrop', - tableName: target.tableName, - constraintName: target.name, - reason, - }, - { type: 'ConstraintAdd', constraint: source, reason }, - ]; -}; diff --git a/server/src/sql-tools/comparers/enum.comparer.spec.ts b/server/src/sql-tools/comparers/enum.comparer.spec.ts deleted file mode 100644 index d788c7cd71..0000000000 --- a/server/src/sql-tools/comparers/enum.comparer.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { compareEnums } from 'src/sql-tools/comparers/enum.comparer'; -import { DatabaseEnum, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchronize: true }; - -describe('compareEnums', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareEnums().onExtra(testEnum)).toEqual([ - { - enumName: 'test', - type: 'EnumDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareEnums().onMissing(testEnum)).toEqual([ - { - type: 'EnumCreate', - enum: testEnum, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - 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([ - { - enumName: 'test', - type: 'EnumDrop', - reason: 'enum values has changed (foo,bar vs foo,bar,world)', - }, - { - type: 'EnumCreate', - enum: source, - reason: 'enum values has changed (foo,bar vs foo,bar,world)', - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/enum.comparer.ts b/server/src/sql-tools/comparers/enum.comparer.ts deleted file mode 100644 index efc08ae727..0000000000 --- a/server/src/sql-tools/comparers/enum.comparer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types'; - -export const compareEnums = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'EnumCreate', - enum: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'EnumDrop', - enumName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - if (source.values.toString() !== target.values.toString()) { - // TODO add or remove values if the lists are different or the order has changed - const reason = `enum values has changed (${source.values} vs ${target.values})`; - return [ - { - type: 'EnumDrop', - enumName: source.name, - reason, - }, - { - type: 'EnumCreate', - enum: source, - reason, - }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/extension.comparer.spec.ts b/server/src/sql-tools/comparers/extension.comparer.spec.ts deleted file mode 100644 index df70ccc761..0000000000 --- a/server/src/sql-tools/comparers/extension.comparer.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { compareExtensions } from 'src/sql-tools/comparers/extension.comparer'; -import { Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testExtension = { name: 'test', synchronize: true }; - -describe('compareExtensions', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareExtensions().onExtra(testExtension)).toEqual([ - { - extensionName: 'test', - type: 'ExtensionDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareExtensions().onMissing(testExtension)).toEqual([ - { - type: 'ExtensionCreate', - extension: testExtension, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - 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 deleted file mode 100644 index 3cb70dadc4..0000000000 --- a/server/src/sql-tools/comparers/extension.comparer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types'; - -export const compareExtensions = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'ExtensionCreate', - extension: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'ExtensionDrop', - extensionName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: () => { - // 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 deleted file mode 100644 index 3d18aaf50a..0000000000 --- a/server/src/sql-tools/comparers/function.comparer.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { compareFunctions } from 'src/sql-tools/comparers/function.comparer'; -import { DatabaseFunction, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testFunction: DatabaseFunction = { - name: 'test', - expression: 'CREATE FUNCTION something something something', - synchronize: true, -}; - -describe('compareFunctions', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareFunctions().onExtra(testFunction)).toEqual([ - { - functionName: 'test', - type: 'FunctionDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareFunctions().onMissing(testFunction)).toEqual([ - { - type: 'FunctionCreate', - function: testFunction, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should ignore functions with the same hash', () => { - 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([ - { - type: 'FunctionCreate', - reason: 'function expression has changed (SELECT 1 vs SELECT 2)', - function: source, - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/function.comparer.ts b/server/src/sql-tools/comparers/function.comparer.ts deleted file mode 100644 index c6217ee708..0000000000 --- a/server/src/sql-tools/comparers/function.comparer.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types'; - -export const compareFunctions = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'FunctionCreate', - function: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'FunctionDrop', - functionName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - if (source.expression !== target.expression) { - const reason = `function expression has changed (${source.expression} vs ${target.expression})`; - return [ - { - type: 'FunctionCreate', - function: source, - reason, - }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/index.comparer.spec.ts b/server/src/sql-tools/comparers/index.comparer.spec.ts deleted file mode 100644 index 9ae7f34f04..0000000000 --- a/server/src/sql-tools/comparers/index.comparer.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { compareIndexes } from 'src/sql-tools/comparers/index.comparer'; -import { DatabaseIndex, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testIndex: DatabaseIndex = { - name: 'test', - tableName: 'table1', - columnNames: ['column1', 'column2'], - unique: false, - synchronize: true, -}; - -describe('compareIndexes', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareIndexes().onExtra(testIndex)).toEqual([ - { - type: 'IndexDrop', - indexName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareIndexes().onMissing(testIndex)).toEqual([ - { - type: 'IndexCreate', - index: testIndex, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareIndexes().onCompare(testIndex, testIndex)).toEqual([]); - }); - - it('should drop and recreate when column list is different', () => { - const source = { - name: 'test', - tableName: 'table1', - columnNames: ['column1'], - unique: true, - synchronize: true, - }; - const target = { - name: 'test', - tableName: 'table1', - columnNames: ['column1', 'column2'], - unique: true, - synchronize: true, - }; - expect(compareIndexes().onCompare(source, target)).toEqual([ - { - indexName: 'test', - type: 'IndexDrop', - reason: 'columns are different (column1 vs column1,column2)', - }, - { - type: 'IndexCreate', - index: source, - reason: 'columns are different (column1 vs column1,column2)', - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/index.comparer.ts b/server/src/sql-tools/comparers/index.comparer.ts deleted file mode 100644 index e474302c6e..0000000000 --- a/server/src/sql-tools/comparers/index.comparer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { asRenameKey, haveEqualColumns } from 'src/sql-tools/helpers'; -import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types'; - -export const compareIndexes = (): Comparer => ({ - getRenameKey: (index) => { - if (index.override) { - return index.override.value.sql.replace(index.name, 'INDEX_NAME'); - } - - return asRenameKey([index.tableName, ...(index.columnNames || []), index.unique]); - }, - onRename: (source, target) => [ - { - type: 'IndexRename', - tableName: source.tableName, - oldName: target.name, - newName: source.name, - reason: Reason.Rename, - }, - ], - onMissing: (source) => [ - { - type: 'IndexCreate', - index: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'IndexDrop', - indexName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - const sourceUsing = source.using ?? 'btree'; - const targetUsing = target.using ?? 'btree'; - - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (source.unique !== target.unique) { - reason = `uniqueness is different (${source.unique} vs ${target.unique})`; - } else if (sourceUsing !== targetUsing) { - reason = `using method is different (${source.using} vs ${target.using})`; - } else if (source.where !== target.where) { - reason = `where clause is different (${source.where} vs ${target.where})`; - } else if (source.expression !== target.expression) { - reason = `expression is different (${source.expression} vs ${target.expression})`; - } - - if (reason) { - return [ - { type: 'IndexDrop', indexName: target.name, reason }, - { type: 'IndexCreate', index: source, reason }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/override.comparer.spec.ts b/server/src/sql-tools/comparers/override.comparer.spec.ts deleted file mode 100644 index dfa6fa4455..0000000000 --- a/server/src/sql-tools/comparers/override.comparer.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { compareOverrides } from 'src/sql-tools/comparers/override.comparer'; -import { DatabaseOverride, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testOverride: DatabaseOverride = { - name: 'test', - value: { type: 'function', name: 'test_func', sql: 'func implementation' }, - synchronize: true, -}; - -describe('compareOverrides', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareOverrides().onExtra(testOverride)).toEqual([ - { - type: 'OverrideDrop', - overrideName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareOverrides().onMissing(testOverride)).toEqual([ - { - type: 'OverrideCreate', - override: testOverride, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareOverrides().onCompare(testOverride, testOverride)).toEqual([]); - }); - - it('should drop and recreate when the value changes', () => { - const source: DatabaseOverride = { - name: 'test', - value: { - type: 'function', - name: 'test_func', - sql: 'func implementation', - }, - synchronize: true, - }; - const target: DatabaseOverride = { - name: 'test', - value: { - type: 'function', - name: 'test_func', - sql: 'func implementation2', - }, - synchronize: true, - }; - expect(compareOverrides().onCompare(source, target)).toEqual([ - { - override: source, - type: 'OverrideUpdate', - reason: expect.stringContaining('value is different'), - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/override.comparer.ts b/server/src/sql-tools/comparers/override.comparer.ts deleted file mode 100644 index 999770bf69..0000000000 --- a/server/src/sql-tools/comparers/override.comparer.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Comparer, DatabaseOverride, Reason } from 'src/sql-tools/types'; - -export const compareOverrides = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'OverrideCreate', - override: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'OverrideDrop', - overrideName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - if (source.value.name !== target.value.name || source.value.sql !== target.value.sql) { - const sourceValue = JSON.stringify(source.value); - const targetValue = JSON.stringify(target.value); - return [ - { type: 'OverrideUpdate', override: source, reason: `value is different (${sourceValue} vs ${targetValue})` }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/parameter.comparer.spec.ts b/server/src/sql-tools/comparers/parameter.comparer.spec.ts deleted file mode 100644 index 23e6c78118..0000000000 --- a/server/src/sql-tools/comparers/parameter.comparer.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { compareParameters } from 'src/sql-tools/comparers/parameter.comparer'; -import { DatabaseParameter, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testParameter: DatabaseParameter = { - name: 'test', - databaseName: 'immich', - value: 'on', - scope: 'database', - synchronize: true, -}; - -describe('compareParameters', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareParameters().onExtra(testParameter)).toEqual([ - { - type: 'ParameterReset', - databaseName: 'immich', - parameterName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareParameters().onMissing(testParameter)).toEqual([ - { - type: 'ParameterSet', - parameter: testParameter, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - 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 deleted file mode 100644 index 41d0508d70..0000000000 --- a/server/src/sql-tools/comparers/parameter.comparer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types'; - -export const compareParameters = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'ParameterSet', - parameter: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'ParameterReset', - databaseName: target.databaseName, - parameterName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: () => { - // TODO - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/table.comparer.spec.ts b/server/src/sql-tools/comparers/table.comparer.spec.ts deleted file mode 100644 index 909db26ea9..0000000000 --- a/server/src/sql-tools/comparers/table.comparer.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { compareTables } from 'src/sql-tools/comparers/table.comparer'; -import { DatabaseTable, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testTable: DatabaseTable = { - name: 'test', - columns: [], - constraints: [], - indexes: [], - triggers: [], - synchronize: true, -}; - -describe('compareParameters', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareTables({}).onExtra(testTable)).toEqual([ - { - type: 'TableDrop', - tableName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareTables({}).onMissing(testTable)).toEqual([ - { - type: 'TableCreate', - table: testTable, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - 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 deleted file mode 100644 index 6576dce1b1..0000000000 --- a/server/src/sql-tools/comparers/table.comparer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { compareColumns } from 'src/sql-tools/comparers/column.comparer'; -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, SchemaDiffOptions } from 'src/sql-tools/types'; - -export const compareTables = (options: SchemaDiffOptions): Comparer => ({ - onMissing: (source) => [ - { - type: 'TableCreate', - table: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'TableDrop', - tableName: target.name, - reason: Reason.MissingInSource, - }, - ], - 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 deleted file mode 100644 index c80b0d2273..0000000000 --- a/server/src/sql-tools/comparers/trigger.comparer.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { compareTriggers } from 'src/sql-tools/comparers/trigger.comparer'; -import { DatabaseTrigger, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testTrigger: DatabaseTrigger = { - name: 'test', - tableName: 'table1', - timing: 'before', - actions: ['delete'], - scope: 'row', - functionName: 'my_trigger_function', - synchronize: true, -}; - -describe('compareTriggers', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareTriggers().onExtra(testTrigger)).toEqual([ - { - type: 'TriggerDrop', - tableName: 'table1', - triggerName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareTriggers().onMissing(testTrigger)).toEqual([ - { - type: 'TriggerCreate', - trigger: testTrigger, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - 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 }]); - }); - - 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 }]); - }); - - 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 }]); - }); - - 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 }]); - }); - - 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 }]); - }); - - 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 }]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/trigger.comparer.ts b/server/src/sql-tools/comparers/trigger.comparer.ts deleted file mode 100644 index 4ba2d5dba3..0000000000 --- a/server/src/sql-tools/comparers/trigger.comparer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types'; - -export const compareTriggers = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'TriggerCreate', - trigger: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'TriggerDrop', - tableName: target.tableName, - triggerName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - let reason = ''; - if (source.functionName !== target.functionName) { - reason = `function is different (${source.functionName} vs ${target.functionName})`; - } else if (source.actions.join(' OR ') !== target.actions.join(' OR ')) { - reason = `action is different (${source.actions} vs ${target.actions})`; - } else if (source.timing !== target.timing) { - reason = `timing method is different (${source.timing} vs ${target.timing})`; - } else if (source.scope !== target.scope) { - reason = `scope is different (${source.scope} vs ${target.scope})`; - } else if (source.referencingNewTableAs !== target.referencingNewTableAs) { - reason = `new table reference is different (${source.referencingNewTableAs} vs ${target.referencingNewTableAs})`; - } else if (source.referencingOldTableAs !== target.referencingOldTableAs) { - reason = `old table reference is different (${source.referencingOldTableAs} vs ${target.referencingOldTableAs})`; - } - - if (reason) { - return [{ type: 'TriggerCreate', trigger: source, reason }]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/contexts/base-context.ts b/server/src/sql-tools/contexts/base-context.ts deleted file mode 100644 index 0fa7230a00..0000000000 --- a/server/src/sql-tools/contexts/base-context.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { DefaultNamingStrategy } from 'src/sql-tools/naming/default.naming'; -import { HashNamingStrategy } from 'src/sql-tools/naming/hash.naming'; -import { NamingInterface, NamingItem } from 'src/sql-tools/naming/naming.interface'; -import { - BaseContextOptions, - DatabaseEnum, - DatabaseExtension, - DatabaseFunction, - DatabaseOverride, - DatabaseParameter, - DatabaseSchema, - DatabaseTable, -} from 'src/sql-tools/types'; - -const asOverrideKey = (type: string, name: string) => `${type}:${name}`; - -const isNamingInterface = (strategy: any): strategy is NamingInterface => { - return typeof strategy === 'object' && typeof strategy.getName === 'function'; -}; - -const asNamingStrategy = (strategy: 'hash' | 'default' | NamingInterface): NamingInterface => { - if (isNamingInterface(strategy)) { - return strategy; - } - - switch (strategy) { - case 'hash': { - return new HashNamingStrategy(); - } - - default: { - return new DefaultNamingStrategy(); - } - } -}; - -export class BaseContext { - databaseName: string; - schemaName: string; - overrideTableName: string; - - tables: DatabaseTable[] = []; - functions: DatabaseFunction[] = []; - enums: DatabaseEnum[] = []; - extensions: DatabaseExtension[] = []; - parameters: DatabaseParameter[] = []; - overrides: DatabaseOverride[] = []; - warnings: string[] = []; - - private namingStrategy: NamingInterface; - - constructor(options: BaseContextOptions) { - this.databaseName = options.databaseName ?? 'postgres'; - this.schemaName = options.schemaName ?? 'public'; - this.overrideTableName = options.overrideTableName ?? 'migration_overrides'; - this.namingStrategy = asNamingStrategy(options.namingStrategy ?? 'hash'); - } - - getNameFor(item: NamingItem) { - return this.namingStrategy.getName(item); - } - - getTableByName(name: string) { - return this.tables.find((table) => table.name === name); - } - - warn(context: string, message: string) { - this.warnings.push(`[${context}] ${message}`); - } - - build(): DatabaseSchema { - const overrideMap = new Map(); - for (const override of this.overrides) { - const { type, name } = override.value; - overrideMap.set(asOverrideKey(type, name), override); - } - - for (const func of this.functions) { - func.override = overrideMap.get(asOverrideKey('function', func.name)); - } - - for (const { indexes, triggers } of this.tables) { - for (const index of indexes) { - index.override = overrideMap.get(asOverrideKey('index', index.name)); - } - - for (const trigger of triggers) { - trigger.override = overrideMap.get(asOverrideKey('trigger', trigger.name)); - } - } - - return { - databaseName: this.databaseName, - schemaName: this.schemaName, - tables: this.tables, - functions: this.functions, - enums: this.enums, - extensions: this.extensions, - parameters: this.parameters, - overrides: this.overrides, - warnings: this.warnings, - }; - } -} diff --git a/server/src/sql-tools/contexts/processor-context.ts b/server/src/sql-tools/contexts/processor-context.ts deleted file mode 100644 index 3ab196b0af..0000000000 --- a/server/src/sql-tools/contexts/processor-context.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; -import { TableOptions } from 'src/sql-tools/decorators/table.decorator'; -import { DatabaseColumn, DatabaseTable, SchemaFromCodeOptions } from 'src/sql-tools/types'; - -type TableMetadata = { options: TableOptions; object: Function; methodToColumn: Map }; - -export class ProcessorContext extends BaseContext { - constructor(public options: SchemaFromCodeOptions) { - options.createForeignKeyIndexes = options.createForeignKeyIndexes ?? true; - options.overrides = options.overrides ?? false; - super(options); - } - - classToTable: WeakMap = new WeakMap(); - tableToMetadata: WeakMap = new WeakMap(); - - getTableByObject(object: Function) { - return this.classToTable.get(object); - } - - getTableMetadata(table: DatabaseTable) { - const metadata = this.tableToMetadata.get(table); - if (!metadata) { - throw new Error(`Table metadata not found for table: ${table.name}`); - } - return metadata; - } - - addTable(table: DatabaseTable, options: TableOptions, object: Function) { - this.tables.push(table); - this.classToTable.set(object, table); - this.tableToMetadata.set(table, { options, object, methodToColumn: new Map() }); - } - - getColumnByObjectAndPropertyName( - object: object, - propertyName: string | symbol, - ): { table?: DatabaseTable; column?: DatabaseColumn } { - const table = this.getTableByObject(object.constructor); - if (!table) { - return {}; - } - - const tableMetadata = this.tableToMetadata.get(table); - if (!tableMetadata) { - return {}; - } - - const column = tableMetadata.methodToColumn.get(propertyName); - - return { table, column }; - } - - addColumn(table: DatabaseTable, column: DatabaseColumn, options: ColumnOptions, propertyName: string | symbol) { - table.columns.push(column); - const tableMetadata = this.getTableMetadata(table); - tableMetadata.methodToColumn.set(propertyName, column); - } - - warnMissingTable(context: string, object: object, propertyName?: symbol | string) { - const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - this.warn(context, `Unable to find table (${label})`); - } - - warnMissingColumn(context: string, object: object, propertyName?: symbol | string) { - const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - this.warn(context, `Unable to find column (${label})`); - } -} diff --git a/server/src/sql-tools/contexts/reader-context.ts b/server/src/sql-tools/contexts/reader-context.ts deleted file mode 100644 index 94f5c82fc1..0000000000 --- a/server/src/sql-tools/contexts/reader-context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { SchemaFromDatabaseOptions } from 'src/sql-tools/types'; - -export class ReaderContext extends BaseContext { - constructor(public options: SchemaFromDatabaseOptions) { - super(options); - } -} diff --git a/server/src/sql-tools/decorators/after-delete.decorator.ts b/server/src/sql-tools/decorators/after-delete.decorator.ts deleted file mode 100644 index 181bfab6c8..0000000000 --- a/server/src/sql-tools/decorators/after-delete.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/decorators/trigger-function.decorator'; - -export const AfterDeleteTrigger = (options: Omit) => - TriggerFunction({ - timing: 'after', - actions: ['delete'], - ...options, - }); diff --git a/server/src/sql-tools/decorators/after-insert.decorator.ts b/server/src/sql-tools/decorators/after-insert.decorator.ts deleted file mode 100644 index c302a5cebe..0000000000 --- a/server/src/sql-tools/decorators/after-insert.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/decorators/trigger-function.decorator'; - -export const AfterInsertTrigger = (options: Omit) => - TriggerFunction({ - timing: 'after', - actions: ['insert'], - ...options, - }); diff --git a/server/src/sql-tools/decorators/before-update.decorator.ts b/server/src/sql-tools/decorators/before-update.decorator.ts deleted file mode 100644 index 2119e29c9b..0000000000 --- a/server/src/sql-tools/decorators/before-update.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/decorators/trigger-function.decorator'; - -export const BeforeUpdateTrigger = (options: Omit) => - TriggerFunction({ - timing: 'before', - actions: ['update'], - ...options, - }); diff --git a/server/src/sql-tools/decorators/check.decorator.ts b/server/src/sql-tools/decorators/check.decorator.ts deleted file mode 100644 index 56fe1ecc3f..0000000000 --- a/server/src/sql-tools/decorators/check.decorator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type CheckOptions = { - name?: string; - expression: string; - synchronize?: boolean; -}; -export const Check = (options: CheckOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/column.decorator.ts b/server/src/sql-tools/decorators/column.decorator.ts deleted file mode 100644 index e5a0eb52f8..0000000000 --- a/server/src/sql-tools/decorators/column.decorator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; -import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types'; - -export type ColumnValue = null | boolean | string | number | Array | object | Date | (() => string); - -export type ColumnBaseOptions = { - name?: string; - primary?: boolean; - type?: ColumnType; - nullable?: boolean; - length?: number; - default?: ColumnValue; - comment?: string; - synchronize?: boolean; - storage?: ColumnStorage; - identity?: boolean; - index?: boolean; - indexName?: string; - unique?: boolean; - uniqueConstraintName?: string; -}; - -export type ColumnOptions = ColumnBaseOptions & { - enum?: DatabaseEnum; - array?: boolean; -}; - -export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => - void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/configuration-parameter.decorator.ts b/server/src/sql-tools/decorators/configuration-parameter.decorator.ts deleted file mode 100644 index 953027d25c..0000000000 --- a/server/src/sql-tools/decorators/configuration-parameter.decorator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ColumnValue } from 'src/sql-tools/decorators/column.decorator'; -import { register } from 'src/sql-tools/register'; -import { ParameterScope } from 'src/sql-tools/types'; - -export type ConfigurationParameterOptions = { - name: string; - value: ColumnValue; - scope: ParameterScope; - synchronize?: boolean; -}; -export const ConfigurationParameter = (options: ConfigurationParameterOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'configurationParameter', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/create-date-column.decorator.ts b/server/src/sql-tools/decorators/create-date-column.decorator.ts deleted file mode 100644 index 1a3362a614..0000000000 --- a/server/src/sql-tools/decorators/create-date-column.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; diff --git a/server/src/sql-tools/decorators/database.decorator.ts b/server/src/sql-tools/decorators/database.decorator.ts deleted file mode 100644 index 17b2460df6..0000000000 --- a/server/src/sql-tools/decorators/database.decorator.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type DatabaseOptions = { - name?: string; - synchronize?: boolean; -}; -export const Database = (options: DatabaseOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'database', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/delete-date-column.decorator.ts b/server/src/sql-tools/decorators/delete-date-column.decorator.ts deleted file mode 100644 index ca5427c27f..0000000000 --- a/server/src/sql-tools/decorators/delete-date-column.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - nullable: true, - ...options, - }); -}; diff --git a/server/src/sql-tools/decorators/extension.decorator.ts b/server/src/sql-tools/decorators/extension.decorator.ts deleted file mode 100644 index d431cbfd02..0000000000 --- a/server/src/sql-tools/decorators/extension.decorator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type ExtensionOptions = { - name: string; - synchronize?: boolean; -}; -export const Extension = (options: string | ExtensionOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'extension', item: { object, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/extensions.decorator.ts b/server/src/sql-tools/decorators/extensions.decorator.ts deleted file mode 100644 index 724446c5fa..0000000000 --- a/server/src/sql-tools/decorators/extensions.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type ExtensionsOptions = { - name: string; - synchronize?: boolean; -}; -export const Extensions = (options: Array): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => { - for (const option of options) { - register({ type: 'extension', item: { object, options: asOptions(option) } }); - } - }; -}; diff --git a/server/src/sql-tools/decorators/foreign-key-column.decorator.ts b/server/src/sql-tools/decorators/foreign-key-column.decorator.ts deleted file mode 100644 index c9c83f010d..0000000000 --- a/server/src/sql-tools/decorators/foreign-key-column.decorator.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { ForeignKeyAction } from 'src/sql-tools//decorators/foreign-key-constraint.decorator'; -import { ColumnBaseOptions } from 'src/sql-tools/decorators/column.decorator'; -import { register } from 'src/sql-tools/register'; - -export type ForeignKeyColumnOptions = ColumnBaseOptions & { - onUpdate?: ForeignKeyAction; - onDelete?: ForeignKeyAction; - constraintName?: string; -}; - -export const ForeignKeyColumn = (target: () => Function, options: ForeignKeyColumnOptions): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => { - register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } }); - }; -}; diff --git a/server/src/sql-tools/decorators/foreign-key-constraint.decorator.ts b/server/src/sql-tools/decorators/foreign-key-constraint.decorator.ts deleted file mode 100644 index e5d2f513dc..0000000000 --- a/server/src/sql-tools/decorators/foreign-key-constraint.decorator.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; - -export type ForeignKeyConstraintOptions = { - name?: string; - index?: boolean; - indexName?: string; - columns: string[]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - referenceTable: () => Function; - referenceColumns?: string[]; - onUpdate?: ForeignKeyAction; - onDelete?: ForeignKeyAction; - synchronize?: boolean; -}; - -export const ForeignKeyConstraint = (options: ForeignKeyConstraintOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (target: Function) => { - register({ type: 'foreignKeyConstraint', item: { object: target, options } }); - }; -}; diff --git a/server/src/sql-tools/decorators/generated-column.decorator.ts b/server/src/sql-tools/decorators/generated-column.decorator.ts deleted file mode 100644 index 4338b4146c..0000000000 --- a/server/src/sql-tools/decorators/generated-column.decorator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Column, ColumnOptions, ColumnValue } from 'src/sql-tools/decorators/column.decorator'; -import { ColumnType } from 'src/sql-tools/types'; - -export type GeneratedColumnStrategy = 'uuid' | 'identity'; - -export type GenerateColumnOptions = Omit & { - strategy?: GeneratedColumnStrategy; -}; - -export const GeneratedColumn = ({ strategy = 'uuid', ...options }: GenerateColumnOptions): PropertyDecorator => { - let columnType: ColumnType | undefined; - let columnDefault: ColumnValue | undefined; - - switch (strategy) { - case 'uuid': { - columnType = 'uuid'; - columnDefault = () => 'uuid_generate_v4()'; - break; - } - - case 'identity': { - columnType = 'integer'; - options.identity = true; - break; - } - - default: { - throw new Error(`Unsupported strategy for @GeneratedColumn ${strategy}`); - } - } - - return Column({ - type: columnType, - default: columnDefault, - ...options, - }); -}; diff --git a/server/src/sql-tools/decorators/index.decorator.ts b/server/src/sql-tools/decorators/index.decorator.ts deleted file mode 100644 index 1b6d38e390..0000000000 --- a/server/src/sql-tools/decorators/index.decorator.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type IndexOptions = { - name?: string; - unique?: boolean; - expression?: string; - using?: string; - with?: string; - where?: string; - columns?: string[]; - synchronize?: boolean; -}; -export const Index = (options: string | IndexOptions = {}): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/primary-column.decorator.ts b/server/src/sql-tools/decorators/primary-column.decorator.ts deleted file mode 100644 index e605b4be5d..0000000000 --- a/server/src/sql-tools/decorators/primary-column.decorator.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const PrimaryColumn = (options: Omit = {}) => Column({ ...options, primary: true }); diff --git a/server/src/sql-tools/decorators/primary-generated-column.decorator.ts b/server/src/sql-tools/decorators/primary-generated-column.decorator.ts deleted file mode 100644 index 25e125ebf6..0000000000 --- a/server/src/sql-tools/decorators/primary-generated-column.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { GenerateColumnOptions, GeneratedColumn } from 'src/sql-tools/decorators/generated-column.decorator'; - -export const PrimaryGeneratedColumn = (options: Omit = {}) => - GeneratedColumn({ ...options, primary: true }); diff --git a/server/src/sql-tools/decorators/table.decorator.ts b/server/src/sql-tools/decorators/table.decorator.ts deleted file mode 100644 index 7ea5882147..0000000000 --- a/server/src/sql-tools/decorators/table.decorator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type TableOptions = { - name?: string; - primaryConstraintName?: string; - synchronize?: boolean; -}; - -/** Table comments here */ -export const Table = (options: string | TableOptions = {}): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/trigger-function.decorator.ts b/server/src/sql-tools/decorators/trigger-function.decorator.ts deleted file mode 100644 index 17016f7946..0000000000 --- a/server/src/sql-tools/decorators/trigger-function.decorator.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Trigger, TriggerOptions } from 'src/sql-tools/decorators/trigger.decorator'; -import { DatabaseFunction } from 'src/sql-tools/types'; - -export type TriggerFunctionOptions = Omit & { function: DatabaseFunction }; -export const TriggerFunction = (options: TriggerFunctionOptions) => - Trigger({ - name: options.function.name, - ...options, - functionName: options.function.name, - }); diff --git a/server/src/sql-tools/decorators/trigger.decorator.ts b/server/src/sql-tools/decorators/trigger.decorator.ts deleted file mode 100644 index ce9a5c17f7..0000000000 --- a/server/src/sql-tools/decorators/trigger.decorator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { register } from 'src/sql-tools/register'; -import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; - -export type TriggerOptions = { - name?: string; - timing: TriggerTiming; - actions: TriggerAction[]; - scope: TriggerScope; - functionName: string; - referencingNewTableAs?: string; - referencingOldTableAs?: string; - when?: string; - synchronize?: boolean; -}; - -export const Trigger = (options: TriggerOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'trigger', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/unique.decorator.ts b/server/src/sql-tools/decorators/unique.decorator.ts deleted file mode 100644 index 1f61fccb6f..0000000000 --- a/server/src/sql-tools/decorators/unique.decorator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type UniqueOptions = { - name?: string; - columns: string[]; - synchronize?: boolean; -}; -export const Unique = (options: UniqueOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/update-date-column.decorator.ts b/server/src/sql-tools/decorators/update-date-column.decorator.ts deleted file mode 100644 index 68dd50c617..0000000000 --- a/server/src/sql-tools/decorators/update-date-column.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; diff --git a/server/src/sql-tools/helpers.ts b/server/src/sql-tools/helpers.ts deleted file mode 100644 index e0daf8262f..0000000000 --- a/server/src/sql-tools/helpers.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { createHash } from 'node:crypto'; -import { ColumnValue } from 'src/sql-tools/decorators/column.decorator'; -import { Comparer, DatabaseColumn, DatabaseOverride, IgnoreOptions, SchemaDiff } from 'src/sql-tools/types'; - -export const asOptions = (options: string | T): T => { - if (typeof options === 'string') { - return { name: options } as T; - } - - return options; -}; - -export const sha1 = (value: string) => createHash('sha1').update(value).digest('hex'); - -export const fromColumnValue = (columnValue?: ColumnValue) => { - if (columnValue === undefined) { - return; - } - - if (typeof columnValue === 'function') { - return columnValue() as string; - } - - const value = columnValue; - - if (value === null) { - return value; - } - - if (typeof value === 'number') { - return String(value); - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false'; - } - - if (value instanceof Date) { - return `'${value.toISOString()}'`; - } - - if (Array.isArray(value)) { - return "'{}'"; - } - - return `'${String(value)}'`; -}; - -export const setIsEqual = (source: Set, target: Set) => - source.size === target.size && [...source].every((x) => target.has(x)); - -export const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { - return setIsEqual(new Set(sourceColumns), new Set(targetColumns)); -}; - -export const haveEqualOverrides = (source: T, target: T) => { - if (!source.override || !target.override) { - return false; - } - - const sourceValue = source.override.value; - const targetValue = target.override.value; - - return sourceValue.name === targetValue.name && sourceValue.sql === targetValue.sql; -}; - -export const compare = ( - sources: T[], - targets: T[], - options: IgnoreOptions | undefined, - comparer: Comparer, -) => { - options = options || {}; - const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); - const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); - const items: SchemaDiff[] = []; - - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - const missingKeys = new Set(); - const extraKeys = new Set(); - - // common keys - for (const key of keys) { - const source = sourceMap[key]; - const target = targetMap[key]; - - if (isIgnored(source, target, options ?? true)) { - continue; - } - - if (isSynchronizeDisabled(source, target)) { - continue; - } - - if (source && !target) { - missingKeys.add(key); - continue; - } - - if (!source && target) { - extraKeys.add(key); - continue; - } - - if ( - haveEqualOverrides( - source as unknown as { override?: DatabaseOverride }, - target as unknown as { override?: DatabaseOverride }, - ) - ) { - continue; - } - - items.push(...comparer.onCompare(source, target)); - } - - // renames - if (comparer.getRenameKey && comparer.onRename) { - const renameMap: Record = {}; - for (const sourceKey of missingKeys) { - const source = sourceMap[sourceKey]; - const renameKey = comparer.getRenameKey(source); - renameMap[renameKey] = sourceKey; - } - - for (const targetKey of extraKeys) { - const target = targetMap[targetKey]; - const renameKey = comparer.getRenameKey(target); - const sourceKey = renameMap[renameKey]; - if (!sourceKey) { - continue; - } - - const source = sourceMap[sourceKey]; - - items.push(...comparer.onRename(source, target)); - - missingKeys.delete(sourceKey); - extraKeys.delete(targetKey); - } - } - - // missing - for (const key of missingKeys) { - items.push(...comparer.onMissing(sourceMap[key])); - } - - // extra - for (const key of extraKeys) { - items.push(...comparer.onExtra(targetMap[key])); - } - - return items; -}; - -const isIgnored = ( - source: { synchronize?: boolean } | undefined, - target: { synchronize?: boolean } | undefined, - options: IgnoreOptions, -) => { - if (typeof options === 'boolean') { - return !options; - } - return (options.ignoreExtra && !source) || (options.ignoreMissing && !target); -}; - -const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { - return source?.synchronize === false || target?.synchronize === false; -}; - -export const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { - if (source.default === target.default) { - return true; - } - - if (source.default === undefined || target.default === undefined) { - return false; - } - - if ( - withTypeCast(source.default, getColumnType(source)) === target.default || - withTypeCast(target.default, getColumnType(target)) === source.default - ) { - return true; - } - - return false; -}; - -export const getColumnType = (column: DatabaseColumn) => { - let type = column.enumName || column.type; - if (column.isArray) { - type += `[${column.length ?? ''}]`; - } else if (column.length !== undefined) { - type += `(${column.length})`; - } - - return type; -}; - -const withTypeCast = (value: string, type: string) => { - if (!value.startsWith(`'`)) { - value = `'${value}'`; - } - return `${value}::${type}`; -}; - -export const getColumnModifiers = (column: DatabaseColumn) => { - const modifiers: string[] = []; - - if (!column.nullable) { - modifiers.push('NOT NULL'); - } - - if (column.default) { - modifiers.push(`DEFAULT ${column.default}`); - } - if (column.identity) { - modifiers.push(`GENERATED ALWAYS AS IDENTITY`); - } - - return modifiers.length === 0 ? '' : ' ' + modifiers.join(' '); -}; - -export const asColumnComment = (tableName: string, columnName: string, comment: string): string => { - return `COMMENT ON COLUMN "${tableName}"."${columnName}" IS '${comment}';`; -}; - -export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', '); - -export const asJsonString = (value: unknown): string => { - return `'${escape(JSON.stringify(value))}'::jsonb`; -}; - -const escape = (value: string) => { - return value - .replaceAll("'", "''") - .replaceAll(/[\\]/g, '\\\\') - .replaceAll(/[\b]/g, String.raw`\b`) - .replaceAll(/[\f]/g, String.raw`\f`) - .replaceAll(/[\n]/g, String.raw`\n`) - .replaceAll(/[\r]/g, String.raw`\r`) - .replaceAll(/[\t]/g, String.raw`\t`); -}; - -export const asRenameKey = (values: Array) => - values.map((value) => value ?? '').join('|'); diff --git a/server/src/sql-tools/index.ts b/server/src/sql-tools/index.ts deleted file mode 100644 index 0d3e53df51..0000000000 --- a/server/src/sql-tools/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'src/sql-tools/public_api'; diff --git a/server/src/sql-tools/naming/default.naming.ts b/server/src/sql-tools/naming/default.naming.ts deleted file mode 100644 index 807580169d..0000000000 --- a/server/src/sql-tools/naming/default.naming.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { sha1 } from 'src/sql-tools/helpers'; -import { NamingItem } from 'src/sql-tools/naming/naming.interface'; - -const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); - -export class DefaultNamingStrategy { - getName(item: NamingItem): string { - switch (item.type) { - case 'database': { - return asSnakeCase(item.name); - } - - case 'table': { - return asSnakeCase(item.name); - } - - case 'column': { - return item.name; - } - - case 'primaryKey': { - return `${item.tableName}_pkey`; - } - - case 'foreignKey': { - return `${item.tableName}_${item.columnNames.join('_')}_fkey`; - } - - case 'check': { - return `${item.tableName}_${sha1(item.expression).slice(0, 8)}_chk`; - } - - case 'unique': { - return `${item.tableName}_${item.columnNames.join('_')}_uq`; - } - - case 'index': { - if (item.columnNames) { - return `${item.tableName}_${item.columnNames.join('_')}_idx`; - } - - return `${item.tableName}_${sha1(item.expression || item.where || '').slice(0, 8)}_idx`; - } - - case 'trigger': { - return `${item.tableName}_${item.functionName}`; - } - } - } -} diff --git a/server/src/sql-tools/naming/hash.naming.ts b/server/src/sql-tools/naming/hash.naming.ts deleted file mode 100644 index 575d0f1239..0000000000 --- a/server/src/sql-tools/naming/hash.naming.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { sha1 } from 'src/sql-tools/helpers'; -import { DefaultNamingStrategy } from 'src/sql-tools/naming/default.naming'; -import { NamingInterface, NamingItem } from 'src/sql-tools/naming/naming.interface'; - -const fallback = new DefaultNamingStrategy(); - -const asKey = (prefix: string, tableName: string, values: string[]) => - (prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30); - -export class HashNamingStrategy implements NamingInterface { - getName(item: NamingItem): string { - switch (item.type) { - case 'primaryKey': { - return asKey('PK_', item.tableName, item.columnNames); - } - - case 'foreignKey': { - return asKey('FK_', item.tableName, item.columnNames); - } - - case 'check': { - return asKey('CHK_', item.tableName, [item.expression]); - } - - case 'unique': { - return asKey('UQ_', item.tableName, item.columnNames); - } - - case 'index': { - const items: string[] = []; - for (const columnName of item.columnNames ?? []) { - items.push(columnName); - } - - if (item.where) { - items.push(item.where); - } - - return asKey('IDX_', item.tableName, items); - } - - case 'trigger': { - return asKey('TR_', item.tableName, [...item.actions, item.scope, item.timing, item.functionName]); - } - - default: { - return fallback.getName(item); - } - } - } -} diff --git a/server/src/sql-tools/naming/naming.interface.ts b/server/src/sql-tools/naming/naming.interface.ts deleted file mode 100644 index f331a22c46..0000000000 --- a/server/src/sql-tools/naming/naming.interface.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; - -export type NamingItem = - | { - type: 'database'; - name: string; - } - | { - type: 'table'; - name: string; - } - | { - type: 'column'; - name: string; - } - | { - type: 'primaryKey'; - tableName: string; - columnNames: string[]; - } - | { - type: 'foreignKey'; - tableName: string; - columnNames: string[]; - referenceTableName: string; - referenceColumnNames: string[]; - } - | { - type: 'check'; - tableName: string; - expression: string; - } - | { - type: 'unique'; - tableName: string; - columnNames: string[]; - } - | { - type: 'index'; - tableName: string; - columnNames?: string[]; - expression?: string; - where?: string; - } - | { - type: 'trigger'; - tableName: string; - functionName: string; - actions: TriggerAction[]; - scope: TriggerScope; - timing: TriggerTiming; - columnNames?: string[]; - expression?: string; - where?: string; - }; - -export interface NamingInterface { - getName(item: NamingItem): string; -} diff --git a/server/src/sql-tools/processors/check-constraint.processor.ts b/server/src/sql-tools/processors/check-constraint.processor.ts deleted file mode 100644 index 5eba1015bf..0000000000 --- a/server/src/sql-tools/processors/check-constraint.processor.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processCheckConstraints: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'checkConstraint')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Check', object); - continue; - } - - const tableName = table.name; - - table.constraints.push({ - type: ConstraintType.CHECK, - name: options.name || ctx.getNameFor({ type: 'check', tableName, expression: options.expression }), - tableName, - expression: options.expression, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/column.processor.ts b/server/src/sql-tools/processors/column.processor.ts deleted file mode 100644 index 9b499b380b..0000000000 --- a/server/src/sql-tools/processors/column.processor.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; -import { fromColumnValue } from 'src/sql-tools/helpers'; -import { Processor } from 'src/sql-tools/types'; - -export const processColumns: Processor = (ctx, items) => { - for (const { - type, - item: { object, propertyName, options }, - } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { - const table = ctx.getTableByObject(object.constructor); - if (!table) { - ctx.warnMissingTable(type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName); - continue; - } - - const columnName = options.name ?? ctx.getNameFor({ type: 'column', name: String(propertyName) }); - const existingColumn = table.columns.find((column) => column.name === columnName); - if (existingColumn) { - // TODO log warnings if column name is not unique - continue; - } - - let defaultValue = fromColumnValue(options.default); - let nullable = options.nullable ?? false; - - // map `{ default: null }` to `{ nullable: true }` - if (defaultValue === null) { - nullable = true; - defaultValue = undefined; - } - - const isEnum = !!(options as ColumnOptions).enum; - - ctx.addColumn( - table, - { - name: columnName, - tableName: table.name, - primary: options.primary ?? false, - default: defaultValue, - nullable, - isArray: (options as ColumnOptions).array ?? false, - length: options.length, - type: isEnum ? 'enum' : options.type || 'character varying', - enumName: isEnum ? (options as ColumnOptions).enum!.name : undefined, - comment: options.comment, - storage: options.storage, - identity: options.identity, - synchronize: options.synchronize ?? true, - }, - options, - propertyName, - ); - } -}; diff --git a/server/src/sql-tools/processors/configuration-parameter.processor.ts b/server/src/sql-tools/processors/configuration-parameter.processor.ts deleted file mode 100644 index dbb5cd4636..0000000000 --- a/server/src/sql-tools/processors/configuration-parameter.processor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { fromColumnValue } from 'src/sql-tools/helpers'; -import { Processor } from 'src/sql-tools/types'; - -export const processConfigurationParameters: Processor = (ctx, items) => { - for (const { - item: { options }, - } of items.filter((item) => item.type === 'configurationParameter')) { - ctx.parameters.push({ - databaseName: ctx.databaseName, - name: options.name, - value: fromColumnValue(options.value), - scope: options.scope, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/database.processor.ts b/server/src/sql-tools/processors/database.processor.ts deleted file mode 100644 index 9f2e847fd6..0000000000 --- a/server/src/sql-tools/processors/database.processor.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processDatabases: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'database')) { - ctx.databaseName = options.name || ctx.getNameFor({ type: 'database', name: object.name }); - } -}; diff --git a/server/src/sql-tools/processors/enum.processor.ts b/server/src/sql-tools/processors/enum.processor.ts deleted file mode 100644 index 1ef65231c9..0000000000 --- a/server/src/sql-tools/processors/enum.processor.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processEnums: Processor = (ctx, items) => { - for (const { item } of items.filter((item) => item.type === 'enum')) { - // TODO log warnings if enum name is not unique - ctx.enums.push(item); - } -}; diff --git a/server/src/sql-tools/processors/extension.processor.ts b/server/src/sql-tools/processors/extension.processor.ts deleted file mode 100644 index 068c66883c..0000000000 --- a/server/src/sql-tools/processors/extension.processor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processExtensions: Processor = (ctx, items) => { - if (ctx.options.extensions === false) { - return; - } - - for (const { - item: { options }, - } of items.filter((item) => item.type === 'extension')) { - ctx.extensions.push({ - name: options.name, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/foreign-key-column.processor.ts b/server/src/sql-tools/processors/foreign-key-column.processor.ts deleted file mode 100644 index 6d147a78eb..0000000000 --- a/server/src/sql-tools/processors/foreign-key-column.processor.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ActionType, ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processForeignKeyColumns: Processor = (ctx, items) => { - for (const { - item: { object, propertyName, options, target }, - } of items.filter((item) => item.type === 'foreignKeyColumn')) { - const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName); - if (!table) { - ctx.warnMissingTable('@ForeignKeyColumn', object); - continue; - } - - if (!column) { - // should be impossible since they are pre-created in `column.processor.ts` - ctx.warnMissingColumn('@ForeignKeyColumn', object, propertyName); - continue; - } - - const referenceTable = ctx.getTableByObject(target()); - if (!referenceTable) { - ctx.warnMissingTable('@ForeignKeyColumn', object, propertyName); - continue; - } - - const columnNames = [column.name]; - const referenceColumns = referenceTable.columns.filter((column) => column.primary); - - // infer FK column type from reference table - if (referenceColumns.length === 1) { - column.type = referenceColumns[0].type; - } - - const referenceTableName = referenceTable.name; - const referenceColumnNames = referenceColumns.map((column) => column.name); - const name = - options.constraintName || - ctx.getNameFor({ - type: 'foreignKey', - tableName: table.name, - columnNames, - referenceTableName, - referenceColumnNames, - }); - - table.constraints.push({ - name, - tableName: table.name, - columnNames, - type: ConstraintType.FOREIGN_KEY, - referenceTableName, - referenceColumnNames, - onUpdate: options.onUpdate as ActionType, - onDelete: options.onDelete as ActionType, - synchronize: options.synchronize ?? true, - }); - - if (options.unique || options.uniqueConstraintName) { - table.constraints.push({ - name: options.uniqueConstraintName || ctx.getNameFor({ type: 'unique', tableName: table.name, columnNames }), - tableName: table.name, - columnNames, - type: ConstraintType.UNIQUE, - synchronize: options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/processors/foreign-key-constraint.processor.ts b/server/src/sql-tools/processors/foreign-key-constraint.processor.ts deleted file mode 100644 index 39d7508d11..0000000000 --- a/server/src/sql-tools/processors/foreign-key-constraint.processor.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { ActionType, ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processForeignKeyConstraints: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'foreignKeyConstraint')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@ForeignKeyConstraint', { name: 'referenceTable' }); - continue; - } - - const referenceTable = ctx.getTableByObject(options.referenceTable()); - if (!referenceTable) { - const referenceTableName = options.referenceTable()?.name; - ctx.warn( - '@ForeignKeyConstraint.referenceTable', - `Unable to find table` + (referenceTableName ? ` (${referenceTableName})` : ''), - ); - continue; - } - - let missingColumn = false; - - for (const columnName of options.columns) { - if (!table.columns.some(({ name }) => name === columnName)) { - const metadata = ctx.getTableMetadata(table); - ctx.warn('@ForeignKeyConstraint.columns', `Unable to find column (${metadata.object.name}.${columnName})`); - missingColumn = true; - } - } - - for (const columnName of options.referenceColumns || []) { - if (!referenceTable.columns.some(({ name }) => name === columnName)) { - const metadata = ctx.getTableMetadata(referenceTable); - ctx.warn( - '@ForeignKeyConstraint.referenceColumns', - `Unable to find column (${metadata.object.name}.${columnName})`, - ); - missingColumn = true; - } - } - - if (missingColumn) { - continue; - } - - const referenceTableName = referenceTable.name; - const referenceColumnNames = - options.referenceColumns || referenceTable.columns.filter(({ primary }) => primary).map(({ name }) => name); - - const name = - options.name || - ctx.getNameFor({ - type: 'foreignKey', - tableName: table.name, - columnNames: options.columns, - referenceTableName, - referenceColumnNames, - }); - - table.constraints.push({ - type: ConstraintType.FOREIGN_KEY, - name, - tableName: table.name, - columnNames: options.columns, - referenceTableName, - referenceColumnNames, - onUpdate: options.onUpdate as ActionType, - onDelete: options.onDelete as ActionType, - synchronize: options.synchronize ?? true, - }); - - if (options.index === false) { - continue; - } - - if (options.index || options.indexName || ctx.options.createForeignKeyIndexes) { - const indexName = - options.indexName || - ctx.getNameFor({ - type: 'index', - tableName: table.name, - columnNames: options.columns, - }); - table.indexes.push({ - name: indexName, - tableName: table.name, - columnNames: options.columns, - unique: false, - synchronize: options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/processors/function.processor.ts b/server/src/sql-tools/processors/function.processor.ts deleted file mode 100644 index 9b351b77f7..0000000000 --- a/server/src/sql-tools/processors/function.processor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processFunctions: Processor = (ctx, items) => { - if (ctx.options.functions === false) { - return; - } - - for (const { item } of items.filter((item) => item.type === 'function')) { - // TODO log warnings if function name is not unique - ctx.functions.push(item); - } -}; diff --git a/server/src/sql-tools/processors/index.processor.ts b/server/src/sql-tools/processors/index.processor.ts deleted file mode 100644 index 766e83fe8b..0000000000 --- a/server/src/sql-tools/processors/index.processor.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processIndexes: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'index')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Check', object); - continue; - } - - const indexName = - options.name || - ctx.getNameFor({ - type: 'index', - tableName: table.name, - columnNames: options.columns, - where: options.where, - }); - - table.indexes.push({ - name: indexName, - tableName: table.name, - unique: options.unique ?? false, - expression: options.expression, - using: options.using, - with: options.with, - where: options.where, - columnNames: options.columns, - synchronize: options.synchronize ?? true, - }); - } - - // column indexes - for (const { - type, - item: { object, propertyName, options }, - } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { - const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName); - if (!table) { - ctx.warnMissingTable('@Column', object); - continue; - } - - if (!column) { - // should be impossible since they are created in `column.processor.ts` - ctx.warnMissingColumn('@Column', object, propertyName); - continue; - } - - if (options.index === false) { - continue; - } - - const isIndexRequested = - options.indexName || options.index || (type === 'foreignKeyColumn' && ctx.options.createForeignKeyIndexes); - if (!isIndexRequested) { - continue; - } - - const indexName = - options.indexName || - ctx.getNameFor({ - type: 'index', - tableName: table.name, - columnNames: [column.name], - }); - - const isIndexPresent = table.indexes.some((index) => index.name === indexName); - if (isIndexPresent) { - continue; - } - - const isOnlyPrimaryColumn = options.primary && table.columns.filter(({ primary }) => primary === true).length === 1; - if (isOnlyPrimaryColumn) { - // will have an index created by the primary key constraint - continue; - } - - table.indexes.push({ - name: indexName, - tableName: table.name, - unique: false, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/index.ts b/server/src/sql-tools/processors/index.ts deleted file mode 100644 index feb0a82f05..0000000000 --- a/server/src/sql-tools/processors/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { processCheckConstraints } from 'src/sql-tools/processors/check-constraint.processor'; -import { processColumns } from 'src/sql-tools/processors/column.processor'; -import { processConfigurationParameters } from 'src/sql-tools/processors/configuration-parameter.processor'; -import { processDatabases } from 'src/sql-tools/processors/database.processor'; -import { processEnums } from 'src/sql-tools/processors/enum.processor'; -import { processExtensions } from 'src/sql-tools/processors/extension.processor'; -import { processForeignKeyColumns } from 'src/sql-tools/processors/foreign-key-column.processor'; -import { processForeignKeyConstraints } from 'src/sql-tools/processors/foreign-key-constraint.processor'; -import { processFunctions } from 'src/sql-tools/processors/function.processor'; -import { processIndexes } from 'src/sql-tools/processors/index.processor'; -import { processOverrides } from 'src/sql-tools/processors/override.processor'; -import { processPrimaryKeyConstraints } from 'src/sql-tools/processors/primary-key-contraint.processor'; -import { processTables } from 'src/sql-tools/processors/table.processor'; -import { processTriggers } from 'src/sql-tools/processors/trigger.processor'; -import { processUniqueConstraints } from 'src/sql-tools/processors/unique-constraint.processor'; -import { Processor } from 'src/sql-tools/types'; - -export const processors: Processor[] = [ - processDatabases, - processConfigurationParameters, - processEnums, - processExtensions, - processFunctions, - processTables, - processColumns, - processForeignKeyColumns, - processForeignKeyConstraints, - processUniqueConstraints, - processCheckConstraints, - processPrimaryKeyConstraints, - processIndexes, - processTriggers, - processOverrides, -]; diff --git a/server/src/sql-tools/processors/override.processor.ts b/server/src/sql-tools/processors/override.processor.ts deleted file mode 100644 index 67b92fbd40..0000000000 --- a/server/src/sql-tools/processors/override.processor.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { asFunctionCreate } from 'src/sql-tools/transformers/function.transformer'; -import { asIndexCreate } from 'src/sql-tools/transformers/index.transformer'; -import { asTriggerCreate } from 'src/sql-tools/transformers/trigger.transformer'; -import { Processor } from 'src/sql-tools/types'; - -export const processOverrides: Processor = (ctx) => { - if (ctx.options.overrides === false) { - return; - } - - for (const func of ctx.functions) { - if (!func.synchronize) { - continue; - } - - ctx.overrides.push({ - name: `function_${func.name}`, - value: { type: 'function', name: func.name, sql: asFunctionCreate(func) }, - synchronize: true, - }); - } - - for (const { triggers, indexes } of ctx.tables) { - for (const trigger of triggers) { - if (!trigger.synchronize) { - continue; - } - - ctx.overrides.push({ - name: `trigger_${trigger.name}`, - value: { type: 'trigger', name: trigger.name, sql: asTriggerCreate(trigger) }, - synchronize: true, - }); - } - - for (const index of indexes) { - if (!index.synchronize) { - continue; - } - - if (index.expression || index.using || index.with || index.where) { - ctx.overrides.push({ - name: `index_${index.name}`, - value: { type: 'index', name: index.name, sql: asIndexCreate(index) }, - synchronize: true, - }); - } - } - } -}; diff --git a/server/src/sql-tools/processors/primary-key-contraint.processor.ts b/server/src/sql-tools/processors/primary-key-contraint.processor.ts deleted file mode 100644 index 0971bfc337..0000000000 --- a/server/src/sql-tools/processors/primary-key-contraint.processor.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processPrimaryKeyConstraints: Processor = (ctx) => { - for (const table of ctx.tables) { - const columnNames: string[] = []; - - for (const column of table.columns) { - if (column.primary) { - columnNames.push(column.name); - } - } - - if (columnNames.length > 0) { - const tableMetadata = ctx.getTableMetadata(table); - table.constraints.push({ - type: ConstraintType.PRIMARY_KEY, - name: - tableMetadata.options.primaryConstraintName || - ctx.getNameFor({ - type: 'primaryKey', - tableName: table.name, - columnNames, - }), - tableName: table.name, - columnNames, - synchronize: tableMetadata.options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/processors/table.processor.ts b/server/src/sql-tools/processors/table.processor.ts deleted file mode 100644 index 993c9ec45d..0000000000 --- a/server/src/sql-tools/processors/table.processor.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processTables: Processor = (ctx, items) => { - for (const { - item: { options, object }, - } of items.filter((item) => item.type === 'table')) { - const test = ctx.getTableByObject(object); - if (test) { - throw new Error( - `Table ${test.name} has already been registered. Does ${object.name} have two @Table() decorators?`, - ); - } - - ctx.addTable( - { - name: options.name || ctx.getNameFor({ type: 'table', name: object.name }), - columns: [], - constraints: [], - indexes: [], - triggers: [], - synchronize: options.synchronize ?? true, - }, - options, - object, - ); - } -}; diff --git a/server/src/sql-tools/processors/trigger.processor.ts b/server/src/sql-tools/processors/trigger.processor.ts deleted file mode 100644 index b50b42cc49..0000000000 --- a/server/src/sql-tools/processors/trigger.processor.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processTriggers: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'trigger')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Trigger', object); - continue; - } - - const triggerName = - options.name || - ctx.getNameFor({ - type: 'trigger', - tableName: table.name, - actions: options.actions, - scope: options.scope, - timing: options.timing, - functionName: options.functionName, - }); - - table.triggers.push({ - name: triggerName, - tableName: table.name, - timing: options.timing, - actions: options.actions, - when: options.when, - scope: options.scope, - referencingNewTableAs: options.referencingNewTableAs, - referencingOldTableAs: options.referencingOldTableAs, - functionName: options.functionName, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/unique-constraint.processor.ts b/server/src/sql-tools/processors/unique-constraint.processor.ts deleted file mode 100644 index 0cbfc26a70..0000000000 --- a/server/src/sql-tools/processors/unique-constraint.processor.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processUniqueConstraints: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'uniqueConstraint')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Unique', object); - continue; - } - - const tableName = table.name; - const columnNames = options.columns; - - table.constraints.push({ - type: ConstraintType.UNIQUE, - name: options.name || ctx.getNameFor({ type: 'unique', tableName, columnNames }), - tableName, - columnNames, - synchronize: options.synchronize ?? true, - }); - } - - // column level constraints - for (const { - type, - item: { object, propertyName, options }, - } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { - const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName); - if (!table) { - ctx.warnMissingTable('@Column', object); - continue; - } - - if (!column) { - // should be impossible since they are created in `column.processor.ts` - ctx.warnMissingColumn('@Column', object, propertyName); - continue; - } - - if (type === 'column' && !options.primary && (options.unique || options.uniqueConstraintName)) { - const uniqueConstraintName = - options.uniqueConstraintName || - ctx.getNameFor({ - type: 'unique', - tableName: table.name, - columnNames: [column.name], - }); - - table.constraints.push({ - type: ConstraintType.UNIQUE, - name: uniqueConstraintName, - tableName: table.name, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts deleted file mode 100644 index 9e7983383e..0000000000 --- a/server/src/sql-tools/public_api.ts +++ /dev/null @@ -1,31 +0,0 @@ -export * from 'src/sql-tools/decorators/after-delete.decorator'; -export * from 'src/sql-tools/decorators/after-insert.decorator'; -export * from 'src/sql-tools/decorators/before-update.decorator'; -export * from 'src/sql-tools/decorators/check.decorator'; -export * from 'src/sql-tools/decorators/column.decorator'; -export * from 'src/sql-tools/decorators/configuration-parameter.decorator'; -export * from 'src/sql-tools/decorators/create-date-column.decorator'; -export * from 'src/sql-tools/decorators/database.decorator'; -export * from 'src/sql-tools/decorators/delete-date-column.decorator'; -export * from 'src/sql-tools/decorators/extension.decorator'; -export * from 'src/sql-tools/decorators/extensions.decorator'; -export * from 'src/sql-tools/decorators/foreign-key-column.decorator'; -export * from 'src/sql-tools/decorators/foreign-key-constraint.decorator'; -export * from 'src/sql-tools/decorators/generated-column.decorator'; -export * from 'src/sql-tools/decorators/index.decorator'; -export * from 'src/sql-tools/decorators/primary-column.decorator'; -export * from 'src/sql-tools/decorators/primary-generated-column.decorator'; -export * from 'src/sql-tools/decorators/table.decorator'; -export * from 'src/sql-tools/decorators/trigger-function.decorator'; -export * from 'src/sql-tools/decorators/trigger.decorator'; -export * from 'src/sql-tools/decorators/unique.decorator'; -export * from 'src/sql-tools/decorators/update-date-column.decorator'; -export * from 'src/sql-tools/naming/default.naming'; -export * from 'src/sql-tools/naming/hash.naming'; -export * from 'src/sql-tools/naming/naming.interface'; -export * from 'src/sql-tools/register-enum'; -export * from 'src/sql-tools/register-function'; -export { schemaDiff, schemaDiffToSql } from 'src/sql-tools/schema-diff'; -export { schemaFromCode } from 'src/sql-tools/schema-from-code'; -export { schemaFromDatabase } from 'src/sql-tools/schema-from-database'; -export * from 'src/sql-tools/types'; diff --git a/server/src/sql-tools/readers/column.reader.ts b/server/src/sql-tools/readers/column.reader.ts deleted file mode 100644 index 249bd77f2c..0000000000 --- a/server/src/sql-tools/readers/column.reader.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { sql } from 'kysely'; -import { jsonArrayFrom } from 'kysely/helpers/postgres'; -import { ColumnType, DatabaseColumn, Reader } from 'src/sql-tools/types'; - -export const readColumns: Reader = async (ctx, db) => { - const columns = await db - .selectFrom('information_schema.columns as c') - .leftJoin('information_schema.element_types as o', (join) => - join - .onRef('c.table_catalog', '=', 'o.object_catalog') - .onRef('c.table_schema', '=', 'o.object_schema') - .onRef('c.table_name', '=', 'o.object_name') - .on('o.object_type', '=', sql.lit('TABLE')) - .onRef('c.dtd_identifier', '=', 'o.collection_type_identifier'), - ) - .leftJoin('pg_type as t', (join) => - join.onRef('t.typname', '=', 'c.udt_name').on('c.data_type', '=', sql.lit('USER-DEFINED')), - ) - .leftJoin('pg_enum as e', (join) => join.onRef('e.enumtypid', '=', 't.oid')) - .select([ - 'c.table_name', - 'c.column_name', - - // is ARRAY, USER-DEFINED, or data type - 'c.data_type', - 'c.column_default', - 'c.is_nullable', - 'c.character_maximum_length', - - // number types - 'c.numeric_precision', - 'c.numeric_scale', - - // date types - 'c.datetime_precision', - - // user defined type - 'c.udt_catalog', - 'c.udt_schema', - 'c.udt_name', - - // data type for ARRAYs - 'o.data_type as array_type', - ]) - .where('table_schema', '=', ctx.schemaName) - .execute(); - - const enumRaw = await db - .selectFrom('pg_type') - .innerJoin('pg_namespace', (join) => - join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', ctx.schemaName), - ) - .where('typtype', '=', sql.lit('e')) - .select((eb) => [ - 'pg_type.typname as name', - jsonArrayFrom( - eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'), - ).as('values'), - ]) - .execute(); - - const enums = enumRaw.map((item) => ({ name: item.name, values: item.values.map(({ value }) => value) })); - for (const { name, values } of enums) { - ctx.enums.push({ name, values, synchronize: true }); - } - - const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values])); - // add columns to tables - for (const column of columns) { - const table = ctx.getTableByName(column.table_name); - if (!table) { - continue; - } - - const columnName = column.column_name; - - const item: DatabaseColumn = { - type: column.data_type as ColumnType, - // TODO infer this from PK constraints - primary: false, - name: columnName, - tableName: column.table_name, - nullable: column.is_nullable === 'YES', - isArray: column.array_type !== null, - numericPrecision: column.numeric_precision ?? undefined, - numericScale: column.numeric_scale ?? undefined, - length: column.character_maximum_length ?? undefined, - default: column.column_default ?? undefined, - synchronize: true, - }; - - const columnLabel = `${table.name}.${columnName}`; - - switch (column.data_type) { - // array types - case 'ARRAY': { - if (!column.array_type) { - ctx.warnings.push(`Unable to find type for ${columnLabel} (ARRAY)`); - continue; - } - item.type = column.array_type as ColumnType; - break; - } - - // enum types - case 'USER-DEFINED': { - if (!enumMap[column.udt_name]) { - ctx.warnings.push(`Unable to find type for ${columnLabel} (ENUM)`); - continue; - } - - item.type = 'enum'; - item.enumName = column.udt_name; - break; - } - } - - table.columns.push(item); - } -}; diff --git a/server/src/sql-tools/readers/comment.reader.ts b/server/src/sql-tools/readers/comment.reader.ts deleted file mode 100644 index 05cc91e7a9..0000000000 --- a/server/src/sql-tools/readers/comment.reader.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Reader } from 'src/sql-tools/types'; - -export const readComments: Reader = async (ctx, db) => { - const comments = await db - .selectFrom('pg_description as d') - .innerJoin('pg_class as c', 'd.objoid', 'c.oid') - .leftJoin('pg_attribute as a', (join) => - join.onRef('a.attrelid', '=', 'c.oid').onRef('a.attnum', '=', 'd.objsubid'), - ) - .select([ - 'c.relname as object_name', - 'c.relkind as object_type', - 'd.description as value', - 'a.attname as column_name', - ]) - .where('d.description', 'is not', null) - .orderBy('object_type') - .orderBy('object_name') - .execute(); - - for (const comment of comments) { - if (comment.object_type === 'r') { - const table = ctx.getTableByName(comment.object_name); - if (!table) { - continue; - } - - if (comment.column_name) { - const column = table.columns.find(({ name }) => name === comment.column_name); - if (column) { - column.comment = comment.value; - } - } - } - } -}; diff --git a/server/src/sql-tools/readers/constraint.reader.ts b/server/src/sql-tools/readers/constraint.reader.ts deleted file mode 100644 index 662c6f414a..0000000000 --- a/server/src/sql-tools/readers/constraint.reader.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { sql } from 'kysely'; -import { ActionType, ConstraintType, Reader } from 'src/sql-tools/types'; - -export const readConstraints: Reader = async (ctx, db) => { - const constraints = await db - .selectFrom('pg_constraint') - .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_constraint.connamespace') // namespace - .innerJoin('pg_class as source_table', (join) => - join.onRef('source_table.oid', '=', 'pg_constraint.conrelid').on('source_table.relkind', 'in', [ - // ordinary table - sql.lit('r'), - // partitioned table - sql.lit('p'), - // foreign table - sql.lit('f'), - ]), - ) // table - .leftJoin('pg_class as reference_table', 'reference_table.oid', 'pg_constraint.confrelid') // reference table - .select((eb) => [ - 'pg_constraint.contype as constraint_type', - 'pg_constraint.conname as constraint_name', - 'source_table.relname as table_name', - 'reference_table.relname as reference_table_name', - 'pg_constraint.confupdtype as update_action', - 'pg_constraint.confdeltype as delete_action', - // 'pg_constraint.oid as constraint_id', - eb - .selectFrom('pg_attribute') - // matching table for PK, FK, and UQ - .whereRef('pg_attribute.attrelid', '=', 'pg_constraint.conrelid') - .whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."conkey")`) - .select((eb) => eb.fn('json_agg', ['pg_attribute.attname']).as('column_name')) - .as('column_names'), - eb - .selectFrom('pg_attribute') - // matching foreign table for FK - .whereRef('pg_attribute.attrelid', '=', 'pg_constraint.confrelid') - .whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."confkey")`) - .select((eb) => eb.fn('json_agg', ['pg_attribute.attname']).as('column_name')) - .as('reference_column_names'), - eb.fn('pg_get_constraintdef', ['pg_constraint.oid']).as('expression'), - ]) - .where('pg_namespace.nspname', '=', ctx.schemaName) - .execute(); - - for (const constraint of constraints) { - const table = ctx.getTableByName(constraint.table_name); - if (!table) { - continue; - } - - const constraintName = constraint.constraint_name; - - switch (constraint.constraint_type) { - // primary key constraint - case 'p': { - if (!constraint.column_names) { - ctx.warnings.push(`Skipping CONSTRAINT "${constraintName}", no columns found`); - continue; - } - table.constraints.push({ - type: ConstraintType.PRIMARY_KEY, - name: constraintName, - tableName: constraint.table_name, - columnNames: constraint.column_names, - synchronize: true, - }); - break; - } - - // foreign key constraint - case 'f': { - if (!constraint.column_names || !constraint.reference_table_name || !constraint.reference_column_names) { - ctx.warnings.push( - `Skipping CONSTRAINT "${constraintName}", missing either columns, referenced table, or referenced columns,`, - ); - continue; - } - - table.constraints.push({ - type: ConstraintType.FOREIGN_KEY, - name: constraintName, - tableName: constraint.table_name, - columnNames: constraint.column_names, - referenceTableName: constraint.reference_table_name, - referenceColumnNames: constraint.reference_column_names, - onUpdate: asDatabaseAction(constraint.update_action), - onDelete: asDatabaseAction(constraint.delete_action), - synchronize: true, - }); - break; - } - - // unique constraint - case 'u': { - table.constraints.push({ - type: ConstraintType.UNIQUE, - name: constraintName, - tableName: constraint.table_name, - columnNames: constraint.column_names as string[], - synchronize: true, - }); - break; - } - - // check constraint - case 'c': { - table.constraints.push({ - type: ConstraintType.CHECK, - name: constraint.constraint_name, - tableName: constraint.table_name, - expression: constraint.expression.replace('CHECK ', ''), - synchronize: true, - }); - break; - } - } - } -}; - -const asDatabaseAction = (action: string) => { - switch (action) { - case 'a': { - return ActionType.NO_ACTION; - } - case 'c': { - return ActionType.CASCADE; - } - case 'r': { - return ActionType.RESTRICT; - } - case 'n': { - return ActionType.SET_NULL; - } - case 'd': { - return ActionType.SET_DEFAULT; - } - - default: { - return ActionType.NO_ACTION; - } - } -}; diff --git a/server/src/sql-tools/readers/extension.reader.ts b/server/src/sql-tools/readers/extension.reader.ts deleted file mode 100644 index aa33f4d21e..0000000000 --- a/server/src/sql-tools/readers/extension.reader.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Reader } from 'src/sql-tools/types'; - -export const readExtensions: Reader = async (ctx, db) => { - const extensions = await db - .selectFrom('pg_catalog.pg_extension') - // .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_catalog.pg_extension.extnamespace') - // .where('pg_namespace.nspname', '=', schemaName) - .select(['extname as name', 'extversion as version']) - .execute(); - - for (const { name } of extensions) { - ctx.extensions.push({ name, synchronize: true }); - } -}; diff --git a/server/src/sql-tools/readers/function.reader.ts b/server/src/sql-tools/readers/function.reader.ts deleted file mode 100644 index 4696747f52..0000000000 --- a/server/src/sql-tools/readers/function.reader.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readFunctions: Reader = async (ctx, db) => { - const routines = await db - .selectFrom('pg_proc as p') - .innerJoin('pg_namespace', 'pg_namespace.oid', 'p.pronamespace') - .leftJoin('pg_depend as d', (join) => join.onRef('d.objid', '=', 'p.oid').on('d.deptype', '=', sql.lit('e'))) - .where('d.objid', 'is', sql.lit(null)) - .where('p.prokind', '=', sql.lit('f')) - .where('pg_namespace.nspname', '=', ctx.schemaName) - .select((eb) => [ - 'p.proname as name', - eb.fn('pg_get_function_identity_arguments', ['p.oid']).as('arguments'), - eb.fn('pg_get_functiondef', ['p.oid']).as('expression'), - ]) - .execute(); - - for (const { name, expression } of routines) { - ctx.functions.push({ - name, - // TODO read expression from the overrides table - expression, - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/index.reader.ts b/server/src/sql-tools/readers/index.reader.ts deleted file mode 100644 index 26b17a0d19..0000000000 --- a/server/src/sql-tools/readers/index.reader.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readIndexes: Reader = async (ctx, db) => { - const indexes = await db - .selectFrom('pg_index as ix') - // matching index, which has column information - .innerJoin('pg_class as i', 'ix.indexrelid', 'i.oid') - .innerJoin('pg_am as a', 'i.relam', 'a.oid') - // matching table - .innerJoin('pg_class as t', 'ix.indrelid', 't.oid') - // namespace - .innerJoin('pg_namespace', 'pg_namespace.oid', 'i.relnamespace') - // PK and UQ constraints automatically have indexes, so we can ignore those - .leftJoin('pg_constraint', (join) => - join - .onRef('pg_constraint.conindid', '=', 'i.oid') - .on('pg_constraint.contype', 'in', [sql.lit('p'), sql.lit('u')]), - ) - .where('pg_constraint.oid', 'is', null) - .select((eb) => [ - 'i.relname as index_name', - 't.relname as table_name', - 'ix.indisunique as unique', - 'a.amname as using', - eb.fn('pg_get_expr', ['ix.indexprs', 'ix.indrelid']).as('expression'), - eb.fn('pg_get_expr', ['ix.indpred', 'ix.indrelid']).as('where'), - eb - .selectFrom('pg_attribute as a') - .where('t.relkind', '=', sql.lit('r')) - .whereRef('a.attrelid', '=', 't.oid') - // list of columns numbers in the index - .whereRef('a.attnum', '=', sql`any("ix"."indkey")`) - .select((eb) => eb.fn('json_agg', ['a.attname']).as('column_name')) - .as('column_names'), - ]) - .where('pg_namespace.nspname', '=', ctx.schemaName) - .where('ix.indisprimary', '=', sql.lit(false)) - .execute(); - - for (const index of indexes) { - const table = ctx.getTableByName(index.table_name); - if (!table) { - continue; - } - - table.indexes.push({ - name: index.index_name, - tableName: index.table_name, - columnNames: index.column_names ?? undefined, - expression: index.expression ?? undefined, - using: index.using, - where: index.where ?? undefined, - unique: index.unique, - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/index.ts b/server/src/sql-tools/readers/index.ts deleted file mode 100644 index 354f99c7ca..0000000000 --- a/server/src/sql-tools/readers/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readColumns } from 'src/sql-tools/readers/column.reader'; -import { readComments } from 'src/sql-tools/readers/comment.reader'; -import { readConstraints } from 'src/sql-tools/readers/constraint.reader'; -import { readExtensions } from 'src/sql-tools/readers/extension.reader'; -import { readFunctions } from 'src/sql-tools/readers/function.reader'; -import { readIndexes } from 'src/sql-tools/readers/index.reader'; -import { readName } from 'src/sql-tools/readers/name.reader'; -import { readOverrides } from 'src/sql-tools/readers/override.reader'; -import { readParameters } from 'src/sql-tools/readers/parameter.reader'; -import { readTables } from 'src/sql-tools/readers/table.reader'; -import { readTriggers } from 'src/sql-tools/readers/trigger.reader'; -import { Reader } from 'src/sql-tools/types'; - -export const readers: Reader[] = [ - readName, - readParameters, - readExtensions, - readFunctions, - readTables, - readColumns, - readIndexes, - readConstraints, - readTriggers, - readComments, - readOverrides, -]; diff --git a/server/src/sql-tools/readers/name.reader.ts b/server/src/sql-tools/readers/name.reader.ts deleted file mode 100644 index de4f1af3a6..0000000000 --- a/server/src/sql-tools/readers/name.reader.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { QueryResult, sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readName: Reader = async (ctx, db) => { - const result = (await sql`SELECT current_database() as name`.execute(db)) as QueryResult<{ name: string }>; - - ctx.databaseName = result.rows[0].name; -}; diff --git a/server/src/sql-tools/readers/override.reader.ts b/server/src/sql-tools/readers/override.reader.ts deleted file mode 100644 index 34f0004f95..0000000000 --- a/server/src/sql-tools/readers/override.reader.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { sql } from 'kysely'; -import { OverrideType, Reader } from 'src/sql-tools/types'; - -export const readOverrides: Reader = async (ctx, db) => { - try { - const result = await sql - .raw<{ - name: string; - value: { type: OverrideType; name: string; sql: string }; - }>(`SELECT name, value FROM "${ctx.overrideTableName}"`) - .execute(db); - - for (const { name, value } of result.rows) { - ctx.overrides.push({ name, value, synchronize: true }); - } - } catch (error) { - ctx.warn('Overrides', `Error reading override table: ${error}`); - } -}; diff --git a/server/src/sql-tools/readers/parameter.reader.ts b/server/src/sql-tools/readers/parameter.reader.ts deleted file mode 100644 index c5f36591a3..0000000000 --- a/server/src/sql-tools/readers/parameter.reader.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { sql } from 'kysely'; -import { ParameterScope, Reader } from 'src/sql-tools/types'; - -export const readParameters: Reader = async (ctx, db) => { - const parameters = await db - .selectFrom('pg_settings') - .where('source', 'in', [sql.lit('database'), sql.lit('user')]) - .select(['name', 'setting as value', 'source as scope']) - .execute(); - - for (const parameter of parameters) { - ctx.parameters.push({ - name: parameter.name, - value: parameter.value, - databaseName: ctx.databaseName, - scope: parameter.scope as ParameterScope, - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/table.reader.ts b/server/src/sql-tools/readers/table.reader.ts deleted file mode 100644 index 4570179bbf..0000000000 --- a/server/src/sql-tools/readers/table.reader.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readTables: Reader = async (ctx, db) => { - const tables = await db - .selectFrom('information_schema.tables') - .where('table_schema', '=', ctx.schemaName) - .where('table_type', '=', sql.lit('BASE TABLE')) - .selectAll() - .execute(); - - for (const table of tables) { - ctx.tables.push({ - name: table.table_name, - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/trigger.reader.ts b/server/src/sql-tools/readers/trigger.reader.ts deleted file mode 100644 index 92fb1d12bf..0000000000 --- a/server/src/sql-tools/readers/trigger.reader.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Reader, TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; - -export const readTriggers: Reader = async (ctx, db) => { - const triggers = await db - .selectFrom('pg_trigger as t') - .innerJoin('pg_proc as p', 't.tgfoid', 'p.oid') - .innerJoin('pg_namespace as n', 'p.pronamespace', 'n.oid') - .innerJoin('pg_class as c', 't.tgrelid', 'c.oid') - .select((eb) => [ - 't.tgname as name', - 't.tgenabled as enabled', - 't.tgtype as type', - 't.tgconstraint as _constraint', - 't.tgdeferrable as is_deferrable', - 't.tginitdeferred as is_initially_deferred', - 't.tgargs as arguments', - 't.tgoldtable as referencing_old_table_as', - 't.tgnewtable as referencing_new_table_as', - eb.fn('pg_get_expr', ['t.tgqual', 't.tgrelid']).as('when_expression'), - 'p.proname as function_name', - 'c.relname as table_name', - ]) - .where('t.tgisinternal', '=', false) // Exclude internal system triggers - .where('n.nspname', '=', ctx.schemaName) - .execute(); - - // add triggers to tables - for (const trigger of triggers) { - const table = ctx.getTableByName(trigger.table_name); - if (!table) { - continue; - } - - table.triggers.push({ - name: trigger.name, - tableName: trigger.table_name, - functionName: trigger.function_name, - referencingNewTableAs: trigger.referencing_new_table_as ?? undefined, - referencingOldTableAs: trigger.referencing_old_table_as ?? undefined, - when: trigger.when_expression, - synchronize: true, - ...parseTriggerType(trigger.type), - }); - } -}; - -export const hasMask = (input: number, mask: number) => (input & mask) === mask; - -export const parseTriggerType = (type: number) => { - // eslint-disable-next-line unicorn/prefer-math-trunc - const scope: TriggerScope = hasMask(type, 1 << 0) ? 'row' : 'statement'; - - let timing: TriggerTiming = 'after'; - const timingMasks: Array<{ mask: number; value: TriggerTiming }> = [ - { mask: 1 << 1, value: 'before' }, - { mask: 1 << 6, value: 'instead of' }, - ]; - - for (const { mask, value } of timingMasks) { - if (hasMask(type, mask)) { - timing = value; - break; - } - } - - const actions: TriggerAction[] = []; - const actionMasks: Array<{ mask: number; value: TriggerAction }> = [ - { mask: 1 << 2, value: 'insert' }, - { mask: 1 << 3, value: 'delete' }, - { mask: 1 << 4, value: 'update' }, - { mask: 1 << 5, value: 'truncate' }, - ]; - - for (const { mask, value } of actionMasks) { - if (hasMask(type, mask)) { - actions.push(value); - break; - } - } - - if (actions.length === 0) { - throw new Error(`Unable to parse trigger type ${type}`); - } - - return { actions, timing, scope }; -}; diff --git a/server/src/sql-tools/register-enum.ts b/server/src/sql-tools/register-enum.ts deleted file mode 100644 index 5e9b41adcb..0000000000 --- a/server/src/sql-tools/register-enum.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { register } from 'src/sql-tools/register'; -import { DatabaseEnum } from 'src/sql-tools/types'; - -export type EnumOptions = { - name: string; - values: string[]; - synchronize?: boolean; -}; - -export const registerEnum = (options: EnumOptions) => { - const item: DatabaseEnum = { - name: options.name, - values: options.values, - synchronize: options.synchronize ?? true, - }; - - register({ type: 'enum', item }); - - return item; -}; diff --git a/server/src/sql-tools/register-function.ts b/server/src/sql-tools/register-function.ts deleted file mode 100644 index 9f1c84c4fa..0000000000 --- a/server/src/sql-tools/register-function.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { register } from 'src/sql-tools/register'; -import { ColumnType, DatabaseFunction } from 'src/sql-tools/types'; - -export type FunctionOptions = { - name: string; - arguments?: string[]; - returnType: ColumnType | string; - language?: 'SQL' | 'PLPGSQL'; - behavior?: 'immutable' | 'stable' | 'volatile'; - parallel?: 'safe' | 'unsafe' | 'restricted'; - strict?: boolean; - synchronize?: boolean; -} & ({ body: string } | { return: string }); - -export const registerFunction = (options: FunctionOptions) => { - const name = options.name; - const expression = asFunctionExpression(options); - - const item: DatabaseFunction = { - name, - expression, - synchronize: options.synchronize ?? true, - }; - - register({ type: 'function', item }); - - return item; -}; - -const asFunctionExpression = (options: FunctionOptions) => { - const name = options.name; - const sql: string[] = [ - `CREATE OR REPLACE FUNCTION ${name}(${(options.arguments || []).join(', ')})`, - `RETURNS ${options.returnType}`, - ]; - - const flags = [ - options.parallel ? `PARALLEL ${options.parallel.toUpperCase()}` : undefined, - options.strict ? 'STRICT' : undefined, - options.behavior ? options.behavior.toUpperCase() : undefined, - `LANGUAGE ${options.language ?? 'SQL'}`, - ].filter((x) => x !== undefined); - - if (flags.length > 0) { - sql.push(flags.join(' ')); - } - - if ('return' in options) { - sql.push(` RETURN ${options.return}`); - } - - if ('body' in options) { - const body = options.body; - sql.push(...(body.includes('\n') ? [`AS $$`, ' ' + body.trim(), `$$;`] : [`AS $$${body}$$;`])); - } - - return sql.join('\n ').trim(); -}; diff --git a/server/src/sql-tools/register-item.ts b/server/src/sql-tools/register-item.ts deleted file mode 100644 index fede281a1b..0000000000 --- a/server/src/sql-tools/register-item.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { CheckOptions } from 'src/sql-tools/decorators/check.decorator'; -import { ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; -import { ConfigurationParameterOptions } from 'src/sql-tools/decorators/configuration-parameter.decorator'; -import { DatabaseOptions } from 'src/sql-tools/decorators/database.decorator'; -import { ExtensionOptions } from 'src/sql-tools/decorators/extension.decorator'; -import { ForeignKeyColumnOptions } from 'src/sql-tools/decorators/foreign-key-column.decorator'; -import { ForeignKeyConstraintOptions } from 'src/sql-tools/decorators/foreign-key-constraint.decorator'; -import { IndexOptions } from 'src/sql-tools/decorators/index.decorator'; -import { TableOptions } from 'src/sql-tools/decorators/table.decorator'; -import { TriggerOptions } from 'src/sql-tools/decorators/trigger.decorator'; -import { UniqueOptions } from 'src/sql-tools/decorators/unique.decorator'; -import { DatabaseEnum, DatabaseFunction } from 'src/sql-tools/types'; - -export type ClassBased = { object: Function } & T; -export type PropertyBased = { object: object; propertyName: string | symbol } & T; -export type RegisterItem = - | { type: 'database'; item: ClassBased<{ options: DatabaseOptions }> } - | { type: 'table'; item: ClassBased<{ options: TableOptions }> } - | { type: 'index'; item: ClassBased<{ options: IndexOptions }> } - | { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> } - | { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> } - | { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> } - | { type: 'function'; item: DatabaseFunction } - | { type: 'enum'; item: DatabaseEnum } - | { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> } - | { type: 'extension'; item: ClassBased<{ options: ExtensionOptions }> } - | { type: 'configurationParameter'; item: ClassBased<{ options: ConfigurationParameterOptions }> } - | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => Function }> } - | { type: 'foreignKeyConstraint'; item: ClassBased<{ options: ForeignKeyConstraintOptions }> }; -export type RegisterItemType = Extract['item']; diff --git a/server/src/sql-tools/register.ts b/server/src/sql-tools/register.ts deleted file mode 100644 index 4df04c935a..0000000000 --- a/server/src/sql-tools/register.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RegisterItem } from 'src/sql-tools/register-item'; - -const items: RegisterItem[] = []; - -export const register = (item: RegisterItem) => void items.push(item); - -export const getRegisteredItems = () => items; - -export const resetRegisteredItems = () => { - items.length = 0; -}; diff --git a/server/src/sql-tools/schema-diff.spec.ts b/server/src/sql-tools/schema-diff.spec.ts deleted file mode 100644 index f45fb98bd3..0000000000 --- a/server/src/sql-tools/schema-diff.spec.ts +++ /dev/null @@ -1,689 +0,0 @@ -import { schemaDiff } from 'src/sql-tools/schema-diff'; -import { - ActionType, - ColumnType, - ConstraintType, - DatabaseColumn, - DatabaseConstraint, - DatabaseIndex, - DatabaseSchema, - DatabaseTable, -} from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const fromColumn = (column: Partial>): DatabaseSchema => { - const tableName = 'table1'; - - return { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: tableName, - columns: [ - { - name: 'column1', - primary: false, - synchronize: true, - isArray: false, - type: 'character varying', - nullable: false, - ...column, - tableName, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], - }; -}; - -const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => { - const tableName = constraint?.tableName || 'table1'; - - return { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: tableName, - columns: [ - { - name: 'column1', - primary: false, - synchronize: true, - isArray: false, - type: 'character varying', - nullable: false, - tableName, - }, - ], - indexes: [], - triggers: [], - constraints: constraint ? [constraint] : [], - synchronize: true, - }, - ], - warnings: [], - }; -}; - -const fromIndex = (index?: DatabaseIndex): DatabaseSchema => { - const tableName = index?.tableName || 'table1'; - - return { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: tableName, - columns: [ - { - name: 'column1', - primary: false, - synchronize: true, - isArray: false, - type: 'character varying', - nullable: false, - tableName, - }, - ], - indexes: index ? [index] : [], - constraints: [], - triggers: [], - synchronize: true, - }, - ], - warnings: [], - }; -}; - -const newSchema = (schema: { - name?: string; - tables: Array<{ - name: string; - columns?: Array<{ - name: string; - type?: ColumnType; - nullable?: boolean; - isArray?: boolean; - }>; - indexes?: DatabaseIndex[]; - constraints?: DatabaseConstraint[]; - }>; -}): DatabaseSchema => { - const tables: DatabaseTable[] = []; - - for (const table of schema.tables || []) { - const tableName = table.name; - const columns: DatabaseColumn[] = []; - - for (const column of table.columns || []) { - const columnName = column.name; - - columns.push({ - tableName, - name: columnName, - primary: false, - type: column.type || 'character varying', - isArray: column.isArray ?? false, - nullable: column.nullable ?? false, - synchronize: true, - }); - } - - tables.push({ - name: tableName, - columns, - indexes: table.indexes ?? [], - constraints: table.constraints ?? [], - triggers: [], - synchronize: true, - }); - } - - return { - databaseName: 'immich', - schemaName: schema?.name || 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables, - warnings: [], - }; -}; - -describe(schemaDiff.name, () => { - it('should work', () => { - const diff = schemaDiff(newSchema({ tables: [] }), newSchema({ tables: [] })); - expect(diff.items).toEqual([]); - }); - - describe('table', () => { - describe('TableCreate', () => { - it('should find a missing table', () => { - const column: DatabaseColumn = { - type: 'character varying', - tableName: 'table1', - primary: false, - name: 'column1', - isArray: false, - nullable: false, - synchronize: true, - }; - const diff = schemaDiff( - newSchema({ tables: [{ name: 'table1', columns: [column] }] }), - newSchema({ tables: [] }), - ); - - expect(diff.items).toHaveLength(1); - expect(diff.items[0]).toEqual({ - type: 'TableCreate', - table: { - name: 'table1', - columns: [column], - constraints: [], - indexes: [], - triggers: [], - synchronize: true, - }, - reason: 'missing in target', - }); - }); - }); - - describe('TableDrop', () => { - it('should find an extra table', () => { - const diff = schemaDiff( - newSchema({ tables: [] }), - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - { tables: { ignoreExtra: false } }, - ); - - expect(diff.items).toHaveLength(1); - expect(diff.items[0]).toEqual({ - type: 'TableDrop', - tableName: 'table1', - reason: 'missing in source', - }); - }); - }); - - it('should skip identical tables', () => { - const diff = schemaDiff( - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - ); - - expect(diff.items).toEqual([]); - }); - }); - - describe('column', () => { - describe('ColumnAdd', () => { - it('should find a new column', () => { - const diff = schemaDiff( - newSchema({ - tables: [ - { - name: 'table1', - columns: [{ name: 'column1' }, { name: 'column2' }], - }, - ], - }), - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAdd', - column: { - tableName: 'table1', - isArray: false, - primary: false, - name: 'column2', - nullable: false, - type: 'character varying', - synchronize: true, - }, - reason: 'missing in target', - }, - ]); - }); - }); - - describe('ColumnDrop', () => { - it('should find an extra column', () => { - const diff = schemaDiff( - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - newSchema({ - tables: [ - { - name: 'table1', - columns: [{ name: 'column1' }, { name: 'column2' }], - }, - ], - }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnDrop', - tableName: 'table1', - columnName: 'column2', - reason: 'missing in source', - }, - ]); - }); - }); - - describe('nullable', () => { - it('should make a column nullable', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', nullable: true }), - fromColumn({ name: 'column1', nullable: false }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - nullable: true, - }, - reason: 'nullable is different (true vs false)', - }, - ]); - }); - - it('should make a column non-nullable', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', nullable: false }), - fromColumn({ name: 'column1', nullable: true }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - nullable: false, - }, - reason: 'nullable is different (false vs true)', - }, - ]); - }); - }); - - describe('default', () => { - it('should set a default value to a function', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', default: 'uuid_generate_v4()' }), - fromColumn({ name: 'column1' }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - default: 'uuid_generate_v4()', - }, - reason: 'default is different (uuid_generate_v4() vs undefined)', - }, - ]); - }); - - it('should ignore explicit casts for strings', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'character varying', default: `''` }), - fromColumn({ name: 'column1', type: 'character varying', default: `''::character varying` }), - ); - - expect(diff.items).toEqual([]); - }); - - it('should ignore explicit casts for numbers', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'bigint', default: `0` }), - fromColumn({ name: 'column1', type: 'bigint', default: `'0'::bigint` }), - ); - - expect(diff.items).toEqual([]); - }); - - it('should ignore explicit casts for enums', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `test` }), - fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `'test'::enum1` }), - ); - - expect(diff.items).toEqual([]); - }); - - it('should support arrays, ignoring types', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'character varying', isArray: true, default: "'{}'" }), - fromColumn({ - name: 'column1', - type: 'character varying', - isArray: true, - default: "'{}'::character varying[]", - }), - ); - - expect(diff.items).toEqual([]); - }); - }); - }); - - describe('constraint', () => { - describe('ConstraintAdd', () => { - it('should detect a new constraint', () => { - const diff = schemaDiff( - fromConstraint({ - name: 'PK_test', - type: ConstraintType.PRIMARY_KEY, - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }), - fromConstraint(), - ); - - expect(diff.items).toEqual([ - { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - columnNames: ['id'], - tableName: 'table1', - synchronize: true, - }, - reason: 'missing in target', - }, - ]); - }); - }); - - describe('ConstraintDrop', () => { - it('should detect an extra constraint', () => { - const diff = schemaDiff( - fromConstraint(), - fromConstraint({ - name: 'PK_test', - type: ConstraintType.PRIMARY_KEY, - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }), - ); - - expect(diff.items).toEqual([ - { - type: 'ConstraintDrop', - tableName: 'table1', - constraintName: 'PK_test', - reason: 'missing in source', - }, - ]); - }); - }); - - describe('primary key', () => { - it('should skip identical primary key constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); - - expect(diff.items).toEqual([]); - }); - }); - - describe('foreign key', () => { - it('should skip identical foreign key constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceTableName: 'table2', - referenceColumnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint(constraint), fromConstraint(constraint)); - - expect(diff.items).toEqual([]); - }); - - it('should drop and recreate when the column changes', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceTableName: 'table2', - referenceColumnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff( - fromConstraint(constraint), - fromConstraint({ ...constraint, columnNames: ['parentId2'] }), - ); - - expect(diff.items).toEqual([ - { - constraintName: 'FK_test', - reason: 'columns are different (parentId vs parentId2)', - tableName: 'table1', - type: 'ConstraintDrop', - }, - { - constraint: { - columnNames: ['parentId'], - name: 'FK_test', - referenceColumnNames: ['id'], - referenceTableName: 'table2', - synchronize: true, - tableName: 'table1', - type: 'foreign-key', - }, - reason: 'columns are different (parentId vs parentId2)', - type: 'ConstraintAdd', - }, - ]); - }); - - it('should drop and recreate when the ON DELETE action changes', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceTableName: 'table2', - referenceColumnNames: ['id'], - onDelete: ActionType.CASCADE, - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint(constraint), fromConstraint({ ...constraint, onDelete: undefined })); - - expect(diff.items).toEqual([ - { - constraintName: 'FK_test', - reason: 'ON DELETE action is different (CASCADE vs NO ACTION)', - tableName: 'table1', - type: 'ConstraintDrop', - }, - { - constraint: { - columnNames: ['parentId'], - name: 'FK_test', - referenceColumnNames: ['id'], - referenceTableName: 'table2', - onDelete: ActionType.CASCADE, - synchronize: true, - tableName: 'table1', - type: 'foreign-key', - }, - reason: 'ON DELETE action is different (CASCADE vs NO ACTION)', - type: 'ConstraintAdd', - }, - ]); - }); - }); - - describe('unique', () => { - it('should skip identical unique constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); - - expect(diff.items).toEqual([]); - }); - }); - - describe('check', () => { - it('should skip identical check constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: 'column1 > 0', - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); - - expect(diff.items).toEqual([]); - }); - }); - }); - - describe('index', () => { - describe('IndexCreate', () => { - it('should detect a new index', () => { - const diff = schemaDiff( - fromIndex({ - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: false, - synchronize: true, - }), - fromIndex(), - ); - - expect(diff.items).toEqual([ - { - type: 'IndexCreate', - index: { - name: 'IDX_test', - columnNames: ['id'], - tableName: 'table1', - unique: false, - synchronize: true, - }, - reason: 'missing in target', - }, - ]); - }); - }); - - describe('IndexDrop', () => { - it('should detect an extra index', () => { - const diff = schemaDiff( - fromIndex(), - fromIndex({ - name: 'IDX_test', - unique: true, - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }), - ); - - expect(diff.items).toEqual([ - { - type: 'IndexDrop', - indexName: 'IDX_test', - reason: 'missing in source', - }, - ]); - }); - }); - - it('should recreate the index if unique changes', () => { - const index: DatabaseIndex = { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: true, - synchronize: true, - }; - const diff = schemaDiff(fromIndex(index), fromIndex({ ...index, unique: false })); - - expect(diff.items).toEqual([ - { - type: 'IndexDrop', - indexName: 'IDX_test', - reason: 'uniqueness is different (true vs false)', - }, - { - type: 'IndexCreate', - index, - reason: 'uniqueness is different (true vs false)', - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/schema-diff.ts b/server/src/sql-tools/schema-diff.ts deleted file mode 100644 index 846210931b..0000000000 --- a/server/src/sql-tools/schema-diff.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { compareEnums } from 'src/sql-tools/comparers/enum.comparer'; -import { compareExtensions } from 'src/sql-tools/comparers/extension.comparer'; -import { compareFunctions } from 'src/sql-tools/comparers/function.comparer'; -import { compareOverrides } from 'src/sql-tools/comparers/override.comparer'; -import { compareParameters } from 'src/sql-tools/comparers/parameter.comparer'; -import { compareTables } from 'src/sql-tools/comparers/table.comparer'; -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { compare } from 'src/sql-tools/helpers'; -import { transformers } from 'src/sql-tools/transformers'; -import { - ConstraintType, - DatabaseSchema, - SchemaDiff, - SchemaDiffOptions, - SchemaDiffToSqlOptions, -} from 'src/sql-tools/types'; - -/** - * Compute the difference between two database schemas - */ -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(options)), - ...compare(source.overrides, target.overrides, options.overrides, compareOverrides()), - ]; - - type SchemaName = SchemaDiff['type']; - const itemMap: Record = { - ColumnRename: [], - ConstraintRename: [], - IndexRename: [], - - ExtensionDrop: [], - ExtensionCreate: [], - - ParameterSet: [], - ParameterReset: [], - - FunctionDrop: [], - FunctionCreate: [], - - EnumDrop: [], - EnumCreate: [], - - TriggerDrop: [], - ConstraintDrop: [], - TableDrop: [], - ColumnDrop: [], - ColumnAdd: [], - ColumnAlter: [], - TableCreate: [], - ConstraintAdd: [], - TriggerCreate: [], - - IndexCreate: [], - IndexDrop: [], - - OverrideCreate: [], - OverrideUpdate: [], - OverrideDrop: [], - }; - - for (const item of items) { - itemMap[item.type].push(item); - } - - const constraintAdds = itemMap.ConstraintAdd.filter((item) => item.type === 'ConstraintAdd'); - - const orderedItems = [ - ...itemMap.ExtensionCreate, - ...itemMap.FunctionCreate, - ...itemMap.ParameterSet, - ...itemMap.ParameterReset, - ...itemMap.EnumCreate, - ...itemMap.TriggerDrop, - ...itemMap.IndexDrop, - ...itemMap.ConstraintDrop, - ...itemMap.TableCreate, - ...itemMap.ColumnAlter, - ...itemMap.ColumnAdd, - ...itemMap.ColumnRename, - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.PRIMARY_KEY), - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.FOREIGN_KEY), - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.UNIQUE), - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.CHECK), - ...itemMap.ConstraintRename, - ...itemMap.IndexCreate, - ...itemMap.IndexRename, - ...itemMap.TriggerCreate, - ...itemMap.ColumnDrop, - ...itemMap.TableDrop, - ...itemMap.EnumDrop, - ...itemMap.FunctionDrop, - ...itemMap.OverrideCreate, - ...itemMap.OverrideUpdate, - ...itemMap.OverrideDrop, - ]; - - return { - items: orderedItems, - asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options), - asHuman: () => schemaDiffToHuman(orderedItems), - }; -}; - -/** - * Convert schema diffs into SQL statements - */ -export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => { - return items.flatMap((item) => asSql(item, options)); -}; - -/** - * 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); - if (!result) { - continue; - } - - return asArray(result).map((result) => result + withComments(options.comments, item)); - } - - 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 ''; - } - - return ` -- ${item.reason}`; -}; - -const asArray = (items: T | T[]): T[] => { - if (Array.isArray(items)) { - return items; - } - - return [items]; -}; diff --git a/server/src/sql-tools/schema-from-code.spec.ts b/server/src/sql-tools/schema-from-code.spec.ts deleted file mode 100644 index b0c88d1f57..0000000000 --- a/server/src/sql-tools/schema-from-code.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { schemaFromCode } from 'src/sql-tools/schema-from-code'; -import { SchemaFromCodeOptions } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const importModule = async (filePath: string) => { - const module = await import(filePath); - const options: SchemaFromCodeOptions = module.options; - - return { module, options }; -}; - -describe(schemaFromCode.name, () => { - it('should work', () => { - expect(schemaFromCode({ reset: true })).toEqual({ - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [], - warnings: [], - }); - }); - - describe('test files', () => { - const errorStubs = readdirSync('test/sql-tools/errors', { withFileTypes: true }); - for (const file of errorStubs) { - const filePath = join(file.parentPath, file.name); - it(filePath, async () => { - const { module, options } = await importModule(filePath); - - expect(module.message).toBeDefined(); - expect(() => schemaFromCode({ ...options, reset: true })).toThrowError(module.message); - }); - } - - const stubs = readdirSync('test/sql-tools', { withFileTypes: true }); - for (const file of stubs) { - if (file.isDirectory()) { - continue; - } - - const filePath = join(file.parentPath, file.name); - it(filePath, async () => { - const { module, options } = await importModule(filePath); - - expect(module.description).toBeDefined(); - expect(module.schema).toBeDefined(); - expect(schemaFromCode({ ...options, reset: true }), module.description).toEqual(module.schema); - }); - } - }); -}); diff --git a/server/src/sql-tools/schema-from-code.ts b/server/src/sql-tools/schema-from-code.ts deleted file mode 100644 index 2e19f414e4..0000000000 --- a/server/src/sql-tools/schema-from-code.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ProcessorContext } from 'src/sql-tools/contexts/processor-context'; -import { processors } from 'src/sql-tools/processors'; -import { getRegisteredItems, resetRegisteredItems } from 'src/sql-tools/register'; -import { ConstraintType, SchemaFromCodeOptions } from 'src/sql-tools/types'; - -/** - * Load schema from code (decorators, etc) - */ -export const schemaFromCode = (options: SchemaFromCodeOptions = {}) => { - try { - const ctx = new ProcessorContext(options); - const items = getRegisteredItems(); - - for (const processor of processors) { - processor(ctx, items); - } - - if (ctx.options.overrides) { - ctx.tables.push({ - name: ctx.overrideTableName, - columns: [ - { - name: 'name', - tableName: ctx.overrideTableName, - primary: true, - type: 'character varying', - nullable: false, - isArray: false, - synchronize: true, - }, - { - name: 'value', - tableName: ctx.overrideTableName, - primary: false, - type: 'jsonb', - nullable: false, - isArray: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: `${ctx.overrideTableName}_pkey`, - tableName: ctx.overrideTableName, - columnNames: ['name'], - synchronize: true, - }, - ], - synchronize: true, - }); - } - - return ctx.build(); - } finally { - if (options.reset) { - resetRegisteredItems(); - } - } -}; diff --git a/server/src/sql-tools/schema-from-database.ts b/server/src/sql-tools/schema-from-database.ts deleted file mode 100644 index ee34e9dd8d..0000000000 --- a/server/src/sql-tools/schema-from-database.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Kysely } from 'kysely'; -import { PostgresJSDialect } from 'kysely-postgres-js'; -import { Sql } from 'postgres'; -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 ( - database: DatabaseLike, - options: SchemaFromDatabaseOptions = {}, -): Promise => { - const db = isKysely(database) - ? (database as Kysely) - : new Kysely({ dialect: new PostgresJSDialect({ postgres: database }) }); - const ctx = new ReaderContext(options); - - try { - for (const reader of readers) { - await reader(ctx, db); - } - - return ctx.build(); - } finally { - // only close the connection it we created it - if (!isKysely(database)) { - await db.destroy(); - } - } -}; diff --git a/server/src/sql-tools/transformers/column.transformer.spec.ts b/server/src/sql-tools/transformers/column.transformer.spec.ts deleted file mode 100644 index 6828e2a72d..0000000000 --- a/server/src/sql-tools/transformers/column.transformer.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformColumns } from 'src/sql-tools/transformers/column.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformColumns.name, () => { - describe('ColumnAdd', () => { - it('should work', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'character varying', - nullable: false, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" character varying NOT NULL;'); - }); - - it('should add a nullable column', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" character varying;'); - }); - - it('should add a column with an enum type', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'character varying', - enumName: 'table1_column1_enum', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" table1_column1_enum;'); - }); - - it('should add a column that is an array type', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'boolean', - nullable: true, - isArray: true, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" boolean[];'); - }); - }); - - describe('ColumnAlter', () => { - it('should make a column nullable', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: true }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]); - }); - - it('should make a column non-nullable', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: false }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]); - }); - - it('should update the default value', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { default: 'uuid_generate_v4()' }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]); - }); - - it('should update the default value to NULL', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - default: 'NULL', - }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT NULL;`]); - }); - }); - - describe('ColumnDrop', () => { - it('should work', () => { - expect( - transformColumns(ctx, { - type: 'ColumnDrop', - tableName: 'table1', - columnName: 'column1', - reason: 'unknown', - }), - ).toEqual(`ALTER TABLE "table1" DROP COLUMN "column1";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/column.transformer.ts b/server/src/sql-tools/transformers/column.transformer.ts deleted file mode 100644 index ffa565e533..0000000000 --- a/server/src/sql-tools/transformers/column.transformer.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { ColumnChanges, DatabaseColumn } from 'src/sql-tools/types'; - -export const transformColumns: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ColumnAdd': { - return asColumnAdd(item.column); - } - - case 'ColumnAlter': { - return asColumnAlter(item.tableName, item.columnName, item.changes); - } - - case 'ColumnRename': { - return `ALTER TABLE "${item.tableName}" RENAME COLUMN "${item.oldName}" TO "${item.newName}";`; - } - - case 'ColumnDrop': { - return `ALTER TABLE "${item.tableName}" DROP COLUMN "${item.columnName}";`; - } - - default: { - return false; - } - } -}; - -const asColumnAdd = (column: DatabaseColumn): string => { - return ( - `ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + getColumnModifiers(column) + ';' - ); -}; - -export const asColumnAlter = (tableName: string, columnName: string, changes: ColumnChanges): string[] => { - const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`; - const items: string[] = []; - if (changes.nullable !== undefined) { - items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`); - } - - if (changes.default !== undefined) { - items.push(`${base} SET DEFAULT ${changes.default};`); - } - - if (changes.storage !== undefined) { - items.push(`${base} SET STORAGE ${changes.storage.toUpperCase()};`); - } - - if (changes.comment !== undefined) { - items.push(asColumnComment(tableName, columnName, changes.comment)); - } - - return items; -}; diff --git a/server/src/sql-tools/transformers/constraint.transformer.spec.ts b/server/src/sql-tools/transformers/constraint.transformer.spec.ts deleted file mode 100644 index 6e512afdca..0000000000 --- a/server/src/sql-tools/transformers/constraint.transformer.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformConstraints } from 'src/sql-tools/transformers/constraint.transformer'; -import { ConstraintType } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformConstraints.name, () => { - describe('ConstraintAdd', () => { - describe('primary keys', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");'); - }); - }); - - describe('foreign keys', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table2', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - 'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;', - ); - }); - }); - - describe('unique', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");'); - }); - }); - - describe('check', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);'); - }); - }); - }); - - describe('ConstraintDrop', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintDrop', - tableName: 'table1', - constraintName: 'PK_test', - reason: 'unknown', - }), - ).toEqual(`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/constraint.transformer.ts b/server/src/sql-tools/transformers/constraint.transformer.ts deleted file mode 100644 index 94421e56fa..0000000000 --- a/server/src/sql-tools/transformers/constraint.transformer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { asColumnList } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { ActionType, ConstraintType, DatabaseConstraint } from 'src/sql-tools/types'; - -export const transformConstraints: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ConstraintAdd': { - return `ALTER TABLE "${item.constraint.tableName}" ADD ${asConstraintBody(item.constraint)};`; - } - - case 'ConstraintRename': { - return `ALTER TABLE "${item.tableName}" RENAME CONSTRAINT "${item.oldName}" TO "${item.newName}";`; - } - - case 'ConstraintDrop': { - return `ALTER TABLE "${item.tableName}" DROP CONSTRAINT "${item.constraintName}";`; - } - default: { - return false; - } - } -}; - -const withAction = (constraint: { onDelete?: ActionType; onUpdate?: ActionType }) => - ` ON UPDATE ${constraint.onUpdate ?? ActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? ActionType.NO_ACTION}`; - -export const asConstraintBody = (constraint: DatabaseConstraint): string => { - const base = `CONSTRAINT "${constraint.name}"`; - - switch (constraint.type) { - case ConstraintType.PRIMARY_KEY: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} PRIMARY KEY (${columnNames})`; - } - - case ConstraintType.FOREIGN_KEY: { - const columnNames = asColumnList(constraint.columnNames); - const referenceColumnNames = asColumnList(constraint.referenceColumnNames); - return ( - `${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` + - withAction(constraint) - ); - } - - case ConstraintType.UNIQUE: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} UNIQUE (${columnNames})`; - } - - case ConstraintType.CHECK: { - return `${base} CHECK (${constraint.expression})`; - } - - default: { - throw new Error(`Unknown constraint type: ${(constraint as any).type}`); - } - } -}; diff --git a/server/src/sql-tools/transformers/enum.transformer.ts b/server/src/sql-tools/transformers/enum.transformer.ts deleted file mode 100644 index cd7bddc2d2..0000000000 --- a/server/src/sql-tools/transformers/enum.transformer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseEnum } from 'src/sql-tools/types'; - -export const transformEnums: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'EnumCreate': { - return asEnumCreate(item.enum); - } - - case 'EnumDrop': { - return asEnumDrop(item.enumName); - } - - default: { - return false; - } - } -}; - -const asEnumCreate = ({ name, values }: DatabaseEnum): string => { - return `CREATE TYPE "${name}" AS ENUM (${values.map((value) => `'${value}'`)});`; -}; - -const asEnumDrop = (enumName: string): string => { - return `DROP TYPE "${enumName}";`; -}; diff --git a/server/src/sql-tools/transformers/extension.transformer.spec.ts b/server/src/sql-tools/transformers/extension.transformer.spec.ts deleted file mode 100644 index 2ab0402875..0000000000 --- a/server/src/sql-tools/transformers/extension.transformer.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformExtensions } from 'src/sql-tools/transformers/extension.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformExtensions.name, () => { - describe('ExtensionDrop', () => { - it('should work', () => { - expect( - transformExtensions(ctx, { - type: 'ExtensionDrop', - extensionName: 'cube', - reason: 'unknown', - }), - ).toEqual(`DROP EXTENSION "cube";`); - }); - }); - - describe('ExtensionCreate', () => { - it('should work', () => { - expect( - transformExtensions(ctx, { - type: 'ExtensionCreate', - extension: { - name: 'cube', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual(`CREATE EXTENSION IF NOT EXISTS "cube";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/extension.transformer.ts b/server/src/sql-tools/transformers/extension.transformer.ts deleted file mode 100644 index 26e76c1157..0000000000 --- a/server/src/sql-tools/transformers/extension.transformer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseExtension } from 'src/sql-tools/types'; - -export const transformExtensions: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ExtensionCreate': { - return asExtensionCreate(item.extension); - } - - case 'ExtensionDrop': { - return asExtensionDrop(item.extensionName); - } - - default: { - return false; - } - } -}; - -const asExtensionCreate = (extension: DatabaseExtension): string => { - return `CREATE EXTENSION IF NOT EXISTS "${extension.name}";`; -}; - -const asExtensionDrop = (extensionName: string): string => { - return `DROP EXTENSION "${extensionName}";`; -}; diff --git a/server/src/sql-tools/transformers/function.transformer.spec.ts b/server/src/sql-tools/transformers/function.transformer.spec.ts deleted file mode 100644 index 5b0ba71c7d..0000000000 --- a/server/src/sql-tools/transformers/function.transformer.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformFunctions } from 'src/sql-tools/transformers/function.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformFunctions.name, () => { - describe('FunctionDrop', () => { - it('should work', () => { - expect( - transformFunctions(ctx, { - type: 'FunctionDrop', - functionName: 'test_func', - reason: 'unknown', - }), - ).toEqual(`DROP FUNCTION test_func;`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/function.transformer.ts b/server/src/sql-tools/transformers/function.transformer.ts deleted file mode 100644 index 42a56cbe13..0000000000 --- a/server/src/sql-tools/transformers/function.transformer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseFunction } from 'src/sql-tools/types'; - -export const transformFunctions: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'FunctionCreate': { - return asFunctionCreate(item.function); - } - - case 'FunctionDrop': { - return asFunctionDrop(item.functionName); - } - - default: { - return false; - } - } -}; - -export const asFunctionCreate = (func: DatabaseFunction): string => { - return func.expression; -}; - -const asFunctionDrop = (functionName: string): string => { - return `DROP FUNCTION ${functionName};`; -}; diff --git a/server/src/sql-tools/transformers/index.transformer.spec.ts b/server/src/sql-tools/transformers/index.transformer.spec.ts deleted file mode 100644 index c9656463bf..0000000000 --- a/server/src/sql-tools/transformers/index.transformer.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformIndexes } from 'src/sql-tools/transformers/index.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformIndexes.name, () => { - describe('IndexCreate', () => { - it('should work', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("column1");'); - }); - - it('should create an unique index', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: true, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1");'); - }); - - it('should create an index with a custom expression', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL);'); - }); - - it('should create an index with a where clause', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: false, - where: '("id" IS NOT NULL)', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL);'); - }); - - it('should create an index with a custom expression', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - using: 'gin', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL);'); - }); - }); - - describe('IndexDrop', () => { - it('should work', () => { - expect( - transformIndexes(ctx, { - type: 'IndexDrop', - indexName: 'IDX_test', - reason: 'unknown', - }), - ).toEqual(`DROP INDEX "IDX_test";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/index.transformer.ts b/server/src/sql-tools/transformers/index.transformer.ts deleted file mode 100644 index acd65140ee..0000000000 --- a/server/src/sql-tools/transformers/index.transformer.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { asColumnList } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseIndex } from 'src/sql-tools/types'; - -export const transformIndexes: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'IndexCreate': { - return asIndexCreate(item.index); - } - - case 'IndexRename': { - return `ALTER INDEX "${item.oldName}" RENAME TO "${item.newName}";`; - } - - case 'IndexDrop': { - return `DROP INDEX "${item.indexName}";`; - } - - default: { - return false; - } - } -}; - -export const asIndexCreate = (index: DatabaseIndex): string => { - let sql = `CREATE`; - - if (index.unique) { - sql += ' UNIQUE'; - } - - sql += ` INDEX "${index.name}" ON "${index.tableName}"`; - - if (index.columnNames) { - const columnNames = asColumnList(index.columnNames); - sql += ` (${columnNames})`; - } - - if (index.using && index.using !== 'btree') { - sql += ` USING ${index.using}`; - } - - if (index.expression) { - sql += ` (${index.expression})`; - } - - if (index.with) { - sql += ` WITH (${index.with})`; - } - - if (index.where) { - sql += ` WHERE ${index.where}`; - } - - return sql + ';'; -}; diff --git a/server/src/sql-tools/transformers/index.ts b/server/src/sql-tools/transformers/index.ts deleted file mode 100644 index 395d69f2e2..0000000000 --- a/server/src/sql-tools/transformers/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { transformColumns } from 'src/sql-tools/transformers/column.transformer'; -import { transformConstraints } from 'src/sql-tools/transformers/constraint.transformer'; -import { transformEnums } from 'src/sql-tools/transformers/enum.transformer'; -import { transformExtensions } from 'src/sql-tools/transformers/extension.transformer'; -import { transformFunctions } from 'src/sql-tools/transformers/function.transformer'; -import { transformIndexes } from 'src/sql-tools/transformers/index.transformer'; -import { transformOverrides } from 'src/sql-tools/transformers/override.transformer'; -import { transformParameters } from 'src/sql-tools/transformers/parameter.transformer'; -import { transformTables } from 'src/sql-tools/transformers/table.transformer'; -import { transformTriggers } from 'src/sql-tools/transformers/trigger.transformer'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; - -export const transformers: SqlTransformer[] = [ - transformColumns, - transformConstraints, - transformEnums, - transformExtensions, - transformFunctions, - transformIndexes, - transformParameters, - transformTables, - transformTriggers, - transformOverrides, -]; diff --git a/server/src/sql-tools/transformers/override.transformer.ts b/server/src/sql-tools/transformers/override.transformer.ts deleted file mode 100644 index 1e2e981128..0000000000 --- a/server/src/sql-tools/transformers/override.transformer.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { asJsonString } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseOverride } from 'src/sql-tools/types'; - -export const transformOverrides: SqlTransformer = (ctx, item) => { - const tableName = ctx.overrideTableName; - - switch (item.type) { - case 'OverrideCreate': { - return asOverrideCreate(tableName, item.override); - } - - case 'OverrideUpdate': { - return asOverrideUpdate(tableName, item.override); - } - - case 'OverrideDrop': { - return asOverrideDrop(tableName, item.overrideName); - } - - default: { - return false; - } - } -}; - -export const asOverrideCreate = (tableName: string, override: DatabaseOverride): string => { - return `INSERT INTO "${tableName}" ("name", "value") VALUES ('${override.name}', ${asJsonString(override.value)});`; -}; - -export const asOverrideUpdate = (tableName: string, override: DatabaseOverride): string => { - return `UPDATE "${tableName}" SET "value" = ${asJsonString(override.value)} WHERE "name" = '${override.name}';`; -}; - -export const asOverrideDrop = (tableName: string, overrideName: string): string => { - return `DELETE FROM "${tableName}" WHERE "name" = '${overrideName}';`; -}; diff --git a/server/src/sql-tools/transformers/parameter.transformer.ts b/server/src/sql-tools/transformers/parameter.transformer.ts deleted file mode 100644 index d23472f991..0000000000 --- a/server/src/sql-tools/transformers/parameter.transformer.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseParameter } from 'src/sql-tools/types'; - -export const transformParameters: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ParameterSet': { - return asParameterSet(item.parameter); - } - - case 'ParameterReset': { - return asParameterReset(item.databaseName, item.parameterName); - } - - default: { - return false; - } - } -}; - -const asParameterSet = (parameter: DatabaseParameter): string => { - let sql = ''; - if (parameter.scope === 'database') { - sql += `ALTER DATABASE "${parameter.databaseName}" `; - } - - sql += `SET ${parameter.name} TO ${parameter.value}`; - - return sql; -}; - -const asParameterReset = (databaseName: string, parameterName: string): string => { - return `ALTER DATABASE "${databaseName}" RESET "${parameterName}"`; -}; diff --git a/server/src/sql-tools/transformers/table.transformer.spec.ts b/server/src/sql-tools/transformers/table.transformer.spec.ts deleted file mode 100644 index 0d89fcd278..0000000000 --- a/server/src/sql-tools/transformers/table.transformer.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformTables } from 'src/sql-tools/transformers/table.transformer'; -import { ConstraintType, DatabaseTable } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -const table1: DatabaseTable = { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - primary: true, - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - { - name: 'column2', - primary: false, - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'index1', - tableName: 'table1', - columnNames: ['column2'], - unique: false, - synchronize: true, - }, - ], - constraints: [ - { - name: 'constraint1', - tableName: 'table1', - columnNames: ['column1'], - type: ConstraintType.PRIMARY_KEY, - synchronize: true, - }, - { - name: 'constraint2', - tableName: 'table1', - columnNames: ['column1'], - type: ConstraintType.FOREIGN_KEY, - referenceTableName: 'table2', - referenceColumnNames: ['parentId'], - synchronize: true, - }, - { - name: 'constraint3', - tableName: 'table1', - columnNames: ['column1'], - type: ConstraintType.UNIQUE, - synchronize: true, - }, - ], - triggers: [], - synchronize: true, -}; - -describe(transformTables.name, () => { - describe('TableDrop', () => { - it('should work', () => { - expect( - transformTables(ctx, { - type: 'TableDrop', - tableName: 'table1', - reason: 'unknown', - }), - ).toEqual(`DROP TABLE "table1";`); - }); - }); - - describe('TableCreate', () => { - it('should work', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: table1, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying, - "column2" character varying, - CONSTRAINT "constraint1" PRIMARY KEY ("column1"), - CONSTRAINT "constraint2" FOREIGN KEY ("column1") REFERENCES "table2" ("parentId") ON UPDATE NO ACTION ON DELETE NO ACTION, - CONSTRAINT "constraint3" UNIQUE ("column1") -);`, - `CREATE INDEX "index1" ON "table1" ("column2");`, - ]); - }); - - it('should handle a non-nullable column', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - primary: false, - name: 'column1', - type: 'character varying', - isArray: false, - nullable: false, - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying NOT NULL -);`, - ]); - }); - - it('should handle a default value', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - primary: false, - type: 'character varying', - isArray: false, - nullable: true, - default: 'uuid_generate_v4()', - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying DEFAULT uuid_generate_v4() -);`, - ]); - }); - - it('should handle a string with a fixed length', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - primary: false, - name: 'column1', - type: 'character varying', - length: 2, - isArray: false, - nullable: true, - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying(2) -);`, - ]); - }); - - it('should handle an array type', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - primary: false, - name: 'column1', - type: 'character varying', - isArray: true, - nullable: true, - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying[] -);`, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/table.transformer.ts b/server/src/sql-tools/transformers/table.transformer.ts deleted file mode 100644 index a81bfc25aa..0000000000 --- a/server/src/sql-tools/transformers/table.transformer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; -import { asColumnAlter } from 'src/sql-tools/transformers/column.transformer'; -import { asConstraintBody } from 'src/sql-tools/transformers/constraint.transformer'; -import { asIndexCreate } from 'src/sql-tools/transformers/index.transformer'; -import { asTriggerCreate } from 'src/sql-tools/transformers/trigger.transformer'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseTable } from 'src/sql-tools/types'; - -export const transformTables: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'TableCreate': { - return asTableCreate(item.table); - } - - case 'TableDrop': { - return asTableDrop(item.tableName); - } - - default: { - return false; - } - } -}; - -const asTableCreate = (table: DatabaseTable) => { - const tableName = table.name; - - const items: string[] = []; - for (const column of table.columns) { - items.push(`"${column.name}" ${getColumnType(column)}${getColumnModifiers(column)}`); - } - - for (const constraint of table.constraints) { - items.push(asConstraintBody(constraint)); - } - - const sql = [`CREATE TABLE "${tableName}" (\n ${items.join(',\n ')}\n);`]; - - for (const column of table.columns) { - if (column.comment) { - sql.push(asColumnComment(tableName, column.name, column.comment)); - } - - if (column.storage) { - sql.push(...asColumnAlter(tableName, column.name, { storage: column.storage })); - } - } - - for (const index of table.indexes) { - sql.push(asIndexCreate(index)); - } - - for (const trigger of table.triggers) { - sql.push(asTriggerCreate(trigger)); - } - - return sql; -}; - -const asTableDrop = (tableName: string) => { - return `DROP TABLE "${tableName}";`; -}; diff --git a/server/src/sql-tools/transformers/trigger.transformer.spec.ts b/server/src/sql-tools/transformers/trigger.transformer.spec.ts deleted file mode 100644 index f6ba889c29..0000000000 --- a/server/src/sql-tools/transformers/trigger.transformer.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformTriggers } from 'src/sql-tools/transformers/trigger.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformTriggers.name, () => { - describe('TriggerCreate', () => { - it('should work', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerCreate', - trigger: { - name: 'trigger1', - tableName: 'table1', - timing: 'before', - actions: ['update'], - scope: 'row', - functionName: 'function1', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - `CREATE OR REPLACE TRIGGER "trigger1" - BEFORE UPDATE ON "table1" - FOR EACH ROW - EXECUTE FUNCTION function1();`, - ); - }); - - it('should work with multiple actions', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerCreate', - trigger: { - name: 'trigger1', - tableName: 'table1', - timing: 'before', - actions: ['update', 'delete'], - scope: 'row', - functionName: 'function1', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - `CREATE OR REPLACE TRIGGER "trigger1" - BEFORE UPDATE OR DELETE ON "table1" - FOR EACH ROW - EXECUTE FUNCTION function1();`, - ); - }); - - it('should work with old/new reference table aliases', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerCreate', - trigger: { - name: 'trigger1', - tableName: 'table1', - timing: 'before', - actions: ['update'], - referencingNewTableAs: 'new', - referencingOldTableAs: 'old', - scope: 'row', - functionName: 'function1', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - `CREATE OR REPLACE TRIGGER "trigger1" - BEFORE UPDATE ON "table1" - REFERENCING OLD TABLE AS "old" NEW TABLE AS "new" - FOR EACH ROW - EXECUTE FUNCTION function1();`, - ); - }); - }); - - describe('TriggerDrop', () => { - it('should work', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerDrop', - tableName: 'table1', - triggerName: 'trigger1', - reason: 'unknown', - }), - ).toEqual(`DROP TRIGGER "trigger1" ON "table1";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/trigger.transformer.ts b/server/src/sql-tools/transformers/trigger.transformer.ts deleted file mode 100644 index fca557abfc..0000000000 --- a/server/src/sql-tools/transformers/trigger.transformer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseTrigger } from 'src/sql-tools/types'; - -export const transformTriggers: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'TriggerCreate': { - return asTriggerCreate(item.trigger); - } - - case 'TriggerDrop': { - return asTriggerDrop(item.tableName, item.triggerName); - } - - default: { - return false; - } - } -}; - -export const asTriggerCreate = (trigger: DatabaseTrigger): string => { - const sql: string[] = [ - `CREATE OR REPLACE TRIGGER "${trigger.name}"`, - `${trigger.timing.toUpperCase()} ${trigger.actions.map((action) => action.toUpperCase()).join(' OR ')} ON "${trigger.tableName}"`, - ]; - - if (trigger.referencingOldTableAs || trigger.referencingNewTableAs) { - let statement = `REFERENCING`; - if (trigger.referencingOldTableAs) { - statement += ` OLD TABLE AS "${trigger.referencingOldTableAs}"`; - } - if (trigger.referencingNewTableAs) { - statement += ` NEW TABLE AS "${trigger.referencingNewTableAs}"`; - } - sql.push(statement); - } - - if (trigger.scope) { - sql.push(`FOR EACH ${trigger.scope.toUpperCase()}`); - } - - if (trigger.when) { - sql.push(`WHEN (${trigger.when})`); - } - - sql.push(`EXECUTE FUNCTION ${trigger.functionName}();`); - - return sql.join('\n '); -}; - -export const asTriggerDrop = (tableName: string, triggerName: string): string => { - return `DROP TRIGGER "${triggerName}" ON "${tableName}";`; -}; diff --git a/server/src/sql-tools/transformers/types.ts b/server/src/sql-tools/transformers/types.ts deleted file mode 100644 index 96cbe4d918..0000000000 --- a/server/src/sql-tools/transformers/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { SchemaDiff } from 'src/sql-tools/types'; - -export type SqlTransformer = (ctx: BaseContext, item: SchemaDiff) => string | string[] | false; diff --git a/server/src/sql-tools/types.ts b/server/src/sql-tools/types.ts deleted file mode 100644 index 9d93a79ff1..0000000000 --- a/server/src/sql-tools/types.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { Kysely, ColumnType as KyselyColumnType } from 'kysely'; -import { ProcessorContext } from 'src/sql-tools/contexts/processor-context'; -import { ReaderContext } from 'src/sql-tools/contexts/reader-context'; -import { NamingInterface } from 'src/sql-tools/naming/naming.interface'; -import { RegisterItem } from 'src/sql-tools/register-item'; - -export type BaseContextOptions = { - databaseName?: string; - schemaName?: string; - overrideTableName?: string; - namingStrategy?: 'default' | 'hash' | NamingInterface; -}; - -export type SchemaFromCodeOptions = BaseContextOptions & { - /** automatically create indexes on foreign key columns */ - createForeignKeyIndexes?: boolean; - reset?: boolean; - - functions?: boolean; - extensions?: boolean; - parameters?: boolean; - overrides?: boolean; -}; - -export type SchemaFromDatabaseOptions = BaseContextOptions; - -export type SchemaDiffToSqlOptions = BaseContextOptions & { - comments?: boolean; -}; - -export type SchemaDiffOptions = BaseContextOptions & { - tables?: IgnoreOptions; - columns?: IgnoreOptions; - indexes?: IgnoreOptions; - triggers?: IgnoreOptions; - constraints?: IgnoreOptions; - functions?: IgnoreOptions; - enums?: IgnoreOptions; - extensions?: IgnoreOptions; - parameters?: IgnoreOptions; - overrides?: IgnoreOptions; -}; - -export type IgnoreOptions = - | boolean - | { - ignoreExtra?: boolean; - ignoreMissing?: boolean; - }; - -export type Processor = (ctx: ProcessorContext, items: RegisterItem[]) => void; -export type Reader = (ctx: ReaderContext, db: DatabaseClient) => Promise; - -export type PostgresDB = { - pg_am: { - oid: number; - amname: string; - amhandler: string; - amtype: string; - }; - - pg_attribute: { - attrelid: number; - attname: string; - attnum: number; - atttypeid: number; - attstattarget: number; - attstatarget: number; - aanum: number; - }; - - pg_class: { - oid: number; - relname: string; - relkind: string; - relnamespace: string; - reltype: string; - relowner: string; - relam: string; - relfilenode: string; - reltablespace: string; - relpages: number; - reltuples: number; - relallvisible: number; - reltoastrelid: string; - relhasindex: PostgresYesOrNo; - relisshared: PostgresYesOrNo; - relpersistence: string; - }; - - pg_constraint: { - oid: number; - conname: string; - conrelid: string; - contype: string; - connamespace: string; - conkey: number[]; - confkey: number[]; - confrelid: string; - confupdtype: string; - confdeltype: string; - confmatchtype: number; - condeferrable: PostgresYesOrNo; - condeferred: PostgresYesOrNo; - convalidated: PostgresYesOrNo; - conindid: number; - }; - - pg_description: { - objoid: string; - classoid: string; - objsubid: number; - description: string; - }; - - pg_trigger: { - oid: string; - tgisinternal: boolean; - tginitdeferred: boolean; - tgdeferrable: boolean; - tgrelid: string; - tgfoid: string; - tgname: string; - tgenabled: string; - tgtype: number; - tgconstraint: string; - tgdeferred: boolean; - tgargs: Buffer; - tgoldtable: string; - tgnewtable: string; - tgqual: string; - }; - - 'pg_catalog.pg_extension': { - oid: string; - extname: string; - extowner: string; - extnamespace: string; - extrelocatable: boolean; - extversion: string; - extconfig: string[]; - extcondition: string[]; - }; - - pg_enum: { - oid: string; - enumtypid: string; - enumsortorder: number; - enumlabel: string; - }; - - pg_index: { - indexrelid: string; - indrelid: string; - indisready: boolean; - indexprs: string | null; - indpred: string | null; - indkey: number[]; - indisprimary: boolean; - indisunique: boolean; - }; - - pg_indexes: { - schemaname: string; - tablename: string; - indexname: string; - tablespace: string | null; - indexrelid: string; - indexdef: string; - }; - - pg_namespace: { - oid: number; - nspname: string; - nspowner: number; - nspacl: string[]; - }; - - pg_type: { - oid: string; - typname: string; - typnamespace: string; - typowner: string; - typtype: string; - typcategory: string; - typarray: string; - }; - - pg_depend: { - objid: string; - deptype: string; - }; - - pg_proc: { - oid: string; - proname: string; - pronamespace: string; - prokind: string; - }; - - pg_settings: { - name: string; - setting: string; - unit: string | null; - category: string; - short_desc: string | null; - extra_desc: string | null; - context: string; - vartype: string; - source: string; - min_val: string | null; - max_val: string | null; - enumvals: string[] | null; - boot_val: string | null; - reset_val: string | null; - sourcefile: string | null; - sourceline: number | null; - pending_restart: PostgresYesOrNo; - }; - - 'information_schema.tables': { - table_catalog: string; - table_schema: string; - table_name: string; - table_type: 'VIEW' | 'BASE TABLE' | string; - is_insertable_info: PostgresYesOrNo; - is_typed: PostgresYesOrNo; - commit_action: string | null; - }; - - 'information_schema.columns': { - table_catalog: string; - table_schema: string; - table_name: string; - column_name: string; - ordinal_position: number; - column_default: string | null; - is_nullable: PostgresYesOrNo; - data_type: string; - dtd_identifier: string; - character_maximum_length: number | null; - character_octet_length: number | null; - numeric_precision: number | null; - numeric_precision_radix: number | null; - numeric_scale: number | null; - datetime_precision: number | null; - interval_type: string | null; - interval_precision: number | null; - udt_catalog: string; - udt_schema: string; - udt_name: string; - maximum_cardinality: number | null; - is_updatable: PostgresYesOrNo; - }; - - 'information_schema.element_types': { - object_catalog: string; - object_schema: string; - object_name: string; - object_type: string; - collection_type_identifier: string; - data_type: string; - }; - - 'information_schema.routines': { - specific_catalog: string; - specific_schema: string; - specific_name: string; - routine_catalog: string; - routine_schema: string; - routine_name: string; - routine_type: string; - data_type: string; - type_udt_catalog: string; - type_udt_schema: string; - type_udt_name: string; - dtd_identifier: string; - routine_body: string; - routine_definition: string; - external_name: string; - external_language: string; - is_deterministic: PostgresYesOrNo; - security_type: string; - }; -}; - -type PostgresYesOrNo = 'YES' | 'NO'; - -export type DatabaseClient = Kysely; - -export enum ConstraintType { - PRIMARY_KEY = 'primary-key', - FOREIGN_KEY = 'foreign-key', - UNIQUE = 'unique', - CHECK = 'check', -} - -export enum ActionType { - NO_ACTION = 'NO ACTION', - RESTRICT = 'RESTRICT', - CASCADE = 'CASCADE', - SET_NULL = 'SET NULL', - SET_DEFAULT = 'SET DEFAULT', -} - -export type ColumnStorage = 'default' | 'external' | 'extended' | 'main'; - -export type ColumnType = - | 'bigint' - | 'boolean' - | 'bytea' - | 'character' - | 'character varying' - | 'date' - | 'double precision' - | 'integer' - | 'jsonb' - | 'polygon' - | 'text' - | 'time' - | 'time with time zone' - | 'time without time zone' - | 'timestamp' - | 'timestamp with time zone' - | 'timestamp without time zone' - | 'uuid' - | 'vector' - | 'enum' - | 'serial' - | 'real'; - -export type DatabaseSchema = { - databaseName: string; - schemaName: string; - functions: DatabaseFunction[]; - enums: DatabaseEnum[]; - tables: DatabaseTable[]; - extensions: DatabaseExtension[]; - parameters: DatabaseParameter[]; - overrides: DatabaseOverride[]; - warnings: string[]; -}; - -export type DatabaseParameter = { - name: string; - databaseName: string; - value: string | number | null | undefined; - scope: ParameterScope; - synchronize: boolean; -}; - -export type ParameterScope = 'database' | 'user'; - -export type DatabaseOverride = { - name: string; - value: { name: string; type: OverrideType; sql: string }; - synchronize: boolean; -}; - -export type OverrideType = 'function' | 'index' | 'trigger'; - -export type DatabaseEnum = { - name: string; - values: string[]; - synchronize: boolean; -}; - -export type DatabaseFunction = { - name: string; - expression: string; - synchronize: boolean; - override?: DatabaseOverride; -}; - -export type DatabaseExtension = { - name: string; - synchronize: boolean; -}; - -export type DatabaseTable = { - name: string; - columns: DatabaseColumn[]; - indexes: DatabaseIndex[]; - constraints: DatabaseConstraint[]; - triggers: DatabaseTrigger[]; - synchronize: boolean; -}; - -export type DatabaseConstraint = - | DatabasePrimaryKeyConstraint - | DatabaseForeignKeyConstraint - | DatabaseUniqueConstraint - | DatabaseCheckConstraint; - -export type DatabaseColumn = { - primary: boolean; - name: string; - tableName: string; - comment?: string; - - type: ColumnType; - nullable: boolean; - isArray: boolean; - synchronize: boolean; - - default?: string; - length?: number; - storage?: ColumnStorage; - identity?: boolean; - - // enum values - enumName?: string; - - // numeric types - numericPrecision?: number; - numericScale?: number; -}; - -export type ColumnChanges = { - nullable?: boolean; - default?: string; - comment?: string; - storage?: ColumnStorage; -}; - -type ColumBasedConstraint = { - name: string; - tableName: string; - columnNames: string[]; -}; - -export type DatabasePrimaryKeyConstraint = ColumBasedConstraint & { - type: ConstraintType.PRIMARY_KEY; - synchronize: boolean; -}; - -export type DatabaseUniqueConstraint = ColumBasedConstraint & { - type: ConstraintType.UNIQUE; - synchronize: boolean; -}; - -export type DatabaseForeignKeyConstraint = ColumBasedConstraint & { - type: ConstraintType.FOREIGN_KEY; - referenceTableName: string; - referenceColumnNames: string[]; - onUpdate?: ActionType; - onDelete?: ActionType; - synchronize: boolean; -}; - -export type DatabaseCheckConstraint = { - type: ConstraintType.CHECK; - name: string; - tableName: string; - expression: string; - synchronize: boolean; -}; - -export type DatabaseTrigger = { - name: string; - tableName: string; - timing: TriggerTiming; - actions: TriggerAction[]; - scope: TriggerScope; - referencingNewTableAs?: string; - referencingOldTableAs?: string; - when?: string; - functionName: string; - override?: DatabaseOverride; - synchronize: boolean; -}; -export type TriggerTiming = 'before' | 'after' | 'instead of'; -export type TriggerAction = 'insert' | 'update' | 'delete' | 'truncate'; -export type TriggerScope = 'row' | 'statement'; - -export type DatabaseIndex = { - name: string; - tableName: string; - columnNames?: string[]; - expression?: string; - unique: boolean; - using?: string; - with?: string; - where?: string; - override?: DatabaseOverride; - synchronize: boolean; -}; - -export type SchemaDiff = { reason: string } & ( - | { type: 'ExtensionCreate'; extension: DatabaseExtension } - | { type: 'ExtensionDrop'; extensionName: string } - | { type: 'FunctionCreate'; function: DatabaseFunction } - | { type: 'FunctionDrop'; functionName: string } - | { type: 'TableCreate'; table: DatabaseTable } - | { type: 'TableDrop'; tableName: string } - | { type: 'ColumnAdd'; column: DatabaseColumn } - | { type: 'ColumnRename'; tableName: string; oldName: string; newName: string } - | { type: 'ColumnAlter'; tableName: string; columnName: string; changes: ColumnChanges } - | { type: 'ColumnDrop'; tableName: string; columnName: string } - | { type: 'ConstraintAdd'; constraint: DatabaseConstraint } - | { type: 'ConstraintRename'; tableName: string; oldName: string; newName: string } - | { type: 'ConstraintDrop'; tableName: string; constraintName: string } - | { type: 'IndexCreate'; index: DatabaseIndex } - | { type: 'IndexRename'; tableName: string; oldName: string; newName: string } - | { type: 'IndexDrop'; indexName: string } - | { type: 'TriggerCreate'; trigger: DatabaseTrigger } - | { type: 'TriggerDrop'; tableName: string; triggerName: string } - | { type: 'ParameterSet'; parameter: DatabaseParameter } - | { type: 'ParameterReset'; databaseName: string; parameterName: string } - | { type: 'EnumCreate'; enum: DatabaseEnum } - | { type: 'EnumDrop'; enumName: string } - | { type: 'OverrideCreate'; override: DatabaseOverride } - | { type: 'OverrideUpdate'; override: DatabaseOverride } - | { type: 'OverrideDrop'; overrideName: string } -); - -export type CompareFunction = (source: T, target: T) => SchemaDiff[]; -export type Comparer = { - onMissing: (source: T) => SchemaDiff[]; - onExtra: (target: T) => SchemaDiff[]; - onCompare: CompareFunction; - /** if two items have the same key, they are considered identical and can be renamed via `onRename` */ - getRenameKey?: (item: T) => string; - onRename?: (source: T, target: T) => SchemaDiff[]; -}; - -export enum Reason { - MissingInSource = 'missing in source', - MissingInTarget = 'missing in target', - Rename = 'name has changed', -} - -export type Timestamp = KyselyColumnType; -export type Generated = - T extends KyselyColumnType - ? KyselyColumnType - : KyselyColumnType; -export type Int8 = KyselyColumnType; diff --git a/server/src/types.ts b/server/src/types.ts index 3e9ea25957..8cf128f497 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -8,7 +8,6 @@ import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { AssetOrder, AssetType, - DatabaseSslMode, ExifOrientation, ImageFormat, JobName, @@ -393,23 +392,6 @@ export type JobItem = export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; -export type DatabaseConnectionURL = { - connectionType: 'url'; - url: string; -}; - -export type DatabaseConnectionParts = { - connectionType: 'parts'; - host: string; - port: number; - username: string; - password: string; - database: string; - ssl?: DatabaseSslMode; -}; - -export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; - export interface ExtensionVersion { name: VectorExtension; availableVersion: string | null; 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/database.spec.ts b/server/src/utils/database.spec.ts deleted file mode 100644 index 4c6a82ad8f..0000000000 --- a/server/src/utils/database.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { asPostgresConnectionConfig } from 'src/utils/database'; - -describe('database utils', () => { - describe('asPostgresConnectionConfig', () => { - it('should handle sslmode=require', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=prefer', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=verify-ca', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=verify-full', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=no-verify', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify', - }), - ).toMatchObject({ ssl: { rejectUnauthorized: false } }); - }); - - it('should handle ssl=true', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true', - }), - ).toMatchObject({ ssl: true }); - }); - - it('should reject invalid ssl', () => { - expect(() => - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid', - }), - ).toThrowError('Invalid ssl option'); - }); - - it('should handle socket: URLs', () => { - expect( - asPostgresConnectionConfig({ connectionType: 'url', url: 'socket:/run/postgresql?db=database1' }), - ).toMatchObject({ host: '/run/postgresql', database: 'database1' }); - }); - - it('should handle sockets in postgres: URLs', () => { - expect( - asPostgresConnectionConfig({ connectionType: 'url', url: 'postgres:///database2?host=/path/to/socket' }), - ).toMatchObject({ - host: '/path/to/socket', - database: 'database2', - }); - }); - }); -}); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 9ae15fd7d5..4dd0c9b302 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,3 +1,4 @@ +import { createPostgres, DatabaseConnectionParams } from '@immich/sql-tools'; import { AliasedRawBuilder, DeduplicateJoinsPlugin, @@ -14,90 +15,24 @@ import { } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; -import { parse } from 'pg-connection-string'; -import postgres, { Notice, PostgresError } from 'postgres'; +import { Notice, PostgresError } from 'postgres'; import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; +import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; -import { DatabaseConnectionParams, VectorExtension } from 'src/types'; - -type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; - -const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl => - typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full'; - -export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => { - if (params.connectionType === 'parts') { - return { - host: params.host, - port: params.port, - username: params.username, - password: params.password, - database: params.database, - ssl: params.ssl === DatabaseSslMode.Disable ? false : params.ssl, - }; - } - - const { host, port, user, password, database, ...rest } = parse(params.url); - let ssl: Ssl | undefined; - if (rest.ssl) { - if (!isValidSsl(rest.ssl)) { - throw new Error(`Invalid ssl option: ${rest.ssl}`); - } - ssl = rest.ssl; - } - - return { - host: host ?? undefined, - port: port ? Number(port) : undefined, - username: user, - password, - database: database ?? undefined, - ssl, - }; -}; - -export const getKyselyConfig = ( - params: DatabaseConnectionParams, - options: Partial>> = {}, -): KyselyConfig => { - const config = asPostgresConnectionConfig(params); +import { VectorExtension } from 'src/types'; +export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { return { dialect: new PostgresJSDialect({ - postgres: postgres({ - onnotice: (notice: Notice) => { + postgres: createPostgres({ + connection, + onNotice: (notice: Notice) => { if (notice['severity'] !== 'NOTICE') { console.warn('Postgres notice:', notice); } }, - max: 10, - types: { - date: { - to: 1184, - from: [1082, 1114, 1184], - serialize: (x: Date | string) => (x instanceof Date ? x.toISOString() : x), - parse: (x: string) => new Date(x), - }, - bigint: { - to: 20, - from: [20, 1700], - parse: (value: string) => Number.parseInt(value), - serialize: (value: number) => value.toString(), - }, - }, - connection: { - TimeZone: 'UTC', - }, - host: config.host, - port: config.port, - username: config.username, - password: config.password, - database: config.database, - ssl: config.ssl, - ...options, }), }), log(event) { diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index c09f3a381b..862ed310bc 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' }, @@ -152,6 +153,33 @@ describe('mimeTypes', () => { } }); + describe('canBeTransparent', () => { + for (const img of [ + 'a.avif', + 'a.bmp', + 'a.gif', + 'a.heic', + 'a.heif', + 'a.hif', + 'a.jxl', + 'a.png', + 'a.svg', + 'a.tif', + 'a.tiff', + 'a.webp', + ]) { + it(`should return true for ${img}`, () => { + expect(mimeTypes.canBeTransparent(img)).toBe(true); + }); + } + + for (const img of ['a.jpg', 'a.jpeg', 'a.jpe', 'a.insp', 'a.jp2', 'a.cr3', 'a.dng', 'a.nef', 'a.arw']) { + it(`should return false for ${img}`, () => { + expect(mimeTypes.canBeTransparent(img)).toBe(false); + }); + } + }); + describe('animated image', () => { for (const img of ['a.avif', 'a.gif', 'a.webp']) { it('should identify animated image mime types as such', () => { @@ -188,7 +216,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..43421e7937 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -77,6 +77,21 @@ const extensionOverrides: Record = { 'image/jpeg': '.jpg', }; +const transparentCapableExtensions = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.heif', + '.hif', + '.jxl', + '.png', + '.svg', + '.tif', + '.tiff', + '.webp', +]); + const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); const profile: Record = Object.fromEntries( Object.entries(image).filter(([key]) => profileExtensions.has(key)), @@ -98,6 +113,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'], @@ -133,6 +149,7 @@ export const mimeTypes = { isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), + canBeTransparent: (filename: string) => transparentCapableExtensions.has(extname(filename).toLowerCase()), isRaw: (filename: string) => isType(filename, raw), lookup, /** return an extension (including a leading `.`) for a mime-type */ @@ -141,9 +158,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/src/validation.ts b/server/src/validation.ts index cdca1bc0ca..ce7ceb602f 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -233,7 +233,7 @@ export const ValidateHexColor = () => { }; type DateOptions = OptionalOptions & { optional?: boolean; format?: 'date' | 'date-time' }; -export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { +export const ValidateDate = (options?: DateOptions & PropertyOptions) => { const { optional, nullable = false, @@ -243,7 +243,7 @@ export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { } = options || {}; return applyDecorators( - ApiProperty({ format, ...apiPropertyOptions }), + Property({ format, ...apiPropertyOptions }), IsDate(), optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(), Transform(({ key, value }) => { diff --git a/server/test/factories/asset-edit.factory.ts b/server/test/factories/asset-edit.factory.ts index e16b0c2e4b..897ed26d61 100644 --- a/server/test/factories/asset-edit.factory.ts +++ b/server/test/factories/asset-edit.factory.ts @@ -1,10 +1,10 @@ import { Selectable } from 'kysely'; -import { AssetEditAction } from 'src/dtos/editing.dto'; +import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetFactory } from 'test/factories/asset.factory'; import { build } from 'test/factories/builder.factory'; import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types'; -import { newUuid } from 'test/small.factory'; +import { newDate, newUuid } from 'test/small.factory'; export class AssetEditFactory { private constructor(private readonly value: Selectable) {} @@ -15,6 +15,7 @@ export class AssetEditFactory { static from(dto: AssetEditLike = {}) { const id = dto.id ?? newUuid(); + const updateId = dto.updateId ?? newUuid(); return new AssetEditFactory({ id, @@ -22,6 +23,8 @@ export class AssetEditFactory { action: AssetEditAction.Crop, parameters: { x: 5, y: 6, width: 200, height: 100 }, sequence: 1, + updateId, + updatedAt: newDate(), ...dto, }); } @@ -33,6 +36,6 @@ export class AssetEditFactory { } build() { - return { ...this.value } as Selectable>; + return { ...this.value } as Omit, 'action' | 'parameters'> & AssetEditActionItem; } } diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts index 899b529766..b2286cad54 100644 --- a/server/test/factories/asset-face.factory.ts +++ b/server/test/factories/asset-face.factory.ts @@ -18,14 +18,14 @@ export class AssetFaceFactory { static from(dto: AssetFaceLike = {}) { return new AssetFaceFactory({ assetId: newUuid(), - boundingBoxX1: 11, - boundingBoxX2: 12, - boundingBoxY1: 21, - boundingBoxY2: 22, + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, deletedAt: null, id: newUuid(), - imageHeight: 42, - imageWidth: 420, + imageHeight: 500, + imageWidth: 400, isVisible: true, personId: null, sourceType: SourceType.MachineLearning, diff --git a/server/test/factories/asset-file.factory.ts b/server/test/factories/asset-file.factory.ts index 109cd5adc4..511ab45bb7 100644 --- a/server/test/factories/asset-file.factory.ts +++ b/server/test/factories/asset-file.factory.ts @@ -26,6 +26,7 @@ export class AssetFileFactory { path: `/data/12/34/thumbs/${id.slice(0, 2)}/${id.slice(2, 4)}/${id}${isEdited ? '_edited' : ''}.jpg`, updateId: newUuidV7(), isProgressive: false, + isTransparent: false, isEdited, ...dto, }); diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 258e2aff38..4d54ba820b 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -96,7 +96,7 @@ export class AssetFactory { } face(dto: AssetFaceLike = {}, builder?: FactoryBuilder) { - this.#faces.push(build(AssetFaceFactory.from(dto), builder)); + this.#faces.push(build(AssetFaceFactory.from({ assetId: this.value?.id, ...dto }), builder)); return this; } diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts deleted file mode 100644 index e01394e84f..0000000000 --- a/server/test/fixtures/face.stub.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { SourceType } from 'src/enum'; -import { AssetFactory } from 'test/factories/asset.factory'; -import { personStub } from 'test/fixtures/person.stub'; - -export const faceStub = { - face1: Object.freeze({ - id: 'assetFaceId1', - assetId: 'asset-id', - asset: { - ...AssetFactory.create({ id: 'asset-id' }), - 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: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - 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: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - 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: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - 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: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - 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: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - 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: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - 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: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - 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 9d48fcc8f8..6ab32e1f02 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -2,171 +2,6 @@ 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'; - -export const personStub = { - noName: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: '', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - hidden: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: '', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: true, - isFavorite: false, - color: 'red', - }), - withName: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: 'Person 1', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - faces: [], - faceAssetId: 'assetFaceId', - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - withBirthDate: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: 'Person 1', - birthDate: new Date('1976-06-30'), - thumbnailPath: '/path/to/thumbnail.jpg', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - noThumbnail: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: '', - birthDate: null, - thumbnailPath: '', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - newThumbnail: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: '', - birthDate: null, - thumbnailPath: '/new/path/to/thumbnail.jpg', - faces: [], - faceAssetId: 'asset-id', - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - primaryPerson: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: 'Person 1', - birthDate: null, - thumbnailPath: '/path/to/thumbnail', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - mergePerson: Object.freeze({ - id: 'person-2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: 'Person 2', - birthDate: null, - thumbnailPath: '/path/to/thumbnail', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - randomPerson: Object.freeze({ - id: 'person-3', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: '', - birthDate: null, - thumbnailPath: '/path/to/thumbnail', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - isFavorite: false, - color: 'red', - }), - isFavorite: Object.freeze({ - id: 'person-4', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - updateId, - ownerId: userStub.admin.id, - name: 'Person 1', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - faces: [], - faceAssetId: 'assetFaceId', - faceAsset: null, - isHidden: false, - isFavorite: true, - color: 'red', - }), -}; - export const personThumbnailStub = { newThumbnailStart: Object.freeze({ ownerId: userStub.admin.id, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 89ca79d864..eb57c10e2e 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,3 +1,6 @@ +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) => { @@ -20,3 +23,29 @@ export const getForStorageTemplate = (asset: ReturnType) 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.factory.ts b/server/test/medium.factory.ts index f1b87b50d7..51dde6b36b 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -6,7 +6,7 @@ import { Stats } from 'node:fs'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; -import { AssetEditActionListDto } from 'src/dtos/editing.dto'; +import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto'; import { AlbumUserRole, AssetType, @@ -282,8 +282,8 @@ export class MediumTestContext { return { tagsAssets, result }; } - async newEdits(assetId: string, dto: AssetEditActionListDto) { - const edits = await this.get(AssetEditRepository).replaceAll(assetId, dto.edits); + async newEdits(assetId: string, dto: AssetEditsCreateDto) { + const edits = await this.get(AssetEditRepository).replaceAll(assetId, dto.edits as AssetEditActionItem[]); return { edits }; } } @@ -634,7 +634,7 @@ const personInsert = (person: Partial> & { ownerId: stri }; }; -const sha256 = (value: string) => createHash('sha256').update(value).digest('base64'); +const sha256 = (value: string) => createHash('sha256').update(value).digest(); const sessionInsert = ({ id = newUuid(), diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 29e7ea7039..2569b29353 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,39 @@ 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; + const editResponse = { ...editAction, id: expect.any(String) }; + await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({ + assetId: asset.id, + edits: [editResponse], + }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ isEdited: true }), + ); + await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editResponse]); + }); + }); }); diff --git a/server/test/medium/specs/services/audit.database.spec.ts b/server/test/medium/specs/services/audit.database.spec.ts index 7506fcf2c3..b4ddf78a4f 100644 --- a/server/test/medium/specs/services/audit.database.spec.ts +++ b/server/test/medium/specs/services/audit.database.spec.ts @@ -1,3 +1,5 @@ +import { AssetEditAction } from 'src/dtos/editing.dto'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { UserRepository } from 'src/repositories/user.repository'; @@ -45,6 +47,27 @@ describe('audit', () => { }); }); + describe('asset_edit_audit', () => { + it('should not cascade asset deletes to asset_edit_audit', async () => { + const assetEditRepo = ctx.get(AssetEditRepository); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + await ctx.database.deleteFrom('asset').where('id', '=', asset.id).execute(); + + await expect( + ctx.database.selectFrom('asset_edit_audit').select(['id']).where('assetId', '=', asset.id).execute(), + ).resolves.toHaveLength(0); + }); + }); + describe('assets_audit', () => { it('should not cascade user deletes to assets_audit', async () => { const userRepo = ctx.get(UserRepository); diff --git a/server/test/medium/specs/services/sync.service.spec.ts b/server/test/medium/specs/services/sync.service.spec.ts index b5443d7e62..c040d584b8 100644 --- a/server/test/medium/specs/services/sync.service.spec.ts +++ b/server/test/medium/specs/services/sync.service.spec.ts @@ -1,9 +1,10 @@ +import { schemaFromCode } from '@immich/sql-tools'; import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { AssetMetadataKey, UserMetadataKey } from 'src/enum'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { SyncRepository } from 'src/repositories/sync.repository'; +import { BaseSync, SyncRepository } from 'src/repositories/sync.repository'; import { DB } from 'src/schema'; import { SyncService } from 'src/services/sync.service'; import { newMediumService } from 'test/medium.factory'; @@ -222,5 +223,21 @@ describe(SyncService.name, () => { expect(after).toHaveLength(1); expect(after[0].id).toBe(keep.id); }); + + it('should cleanup every table', async () => { + const { sut } = setup(); + + const auditTables = schemaFromCode() + .tables.filter((table) => table.name.endsWith('_audit')) + .map(({ name }) => name); + + const auditCleanupSpy = vi.spyOn(BaseSync.prototype as any, 'auditCleanup'); + await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined(); + + expect(auditCleanupSpy).toHaveBeenCalledTimes(auditTables.length); + for (const table of auditTables) { + expect(auditCleanupSpy, `Audit table ${table} was not cleaned up`).toHaveBeenCalledWith(table, 31); + } + }); }); }); diff --git a/server/test/medium/specs/sync/sync-asset-edit.spec.ts b/server/test/medium/specs/sync/sync-asset-edit.spec.ts new file mode 100644 index 0000000000..43b2450b49 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-edit.spec.ts @@ -0,0 +1,300 @@ +import { Kysely } from 'kysely'; +import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncRequestType.AssetEditsV1, () => { + it('should detect and sync the first asset edit', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should detect and sync multiple asset edits for the same asset', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + { + action: AssetEditAction.Rotate, + parameters: { angle: 90 }, + }, + { + action: AssetEditAction.Mirror, + parameters: { axis: MirrorAxis.Horizontal }, + }, + ]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Rotate, + parameters: { angle: 90 }, + sequence: 1, + }, + type: SyncEntityType.AssetEditV1, + }, + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Mirror, + parameters: { axis: MirrorAxis.Horizontal }, + sequence: 2, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should detect and sync updated edits', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + // Create initial edit + const edits = await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + await ctx.syncAckAll(auth, response1); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + + // update the existing edit + await ctx.database + .updateTable('asset_edit') + .set({ + parameters: { x: 50, y: 60, width: 150, height: 250 }, + }) + .where('id', '=', edits[0].id) + .execute(); + + const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response2).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Crop, + parameters: { x: 50, y: 60, width: 150, height: 250 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response2); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should detect and sync deleted asset edits', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + // Create initial edit + const edits = await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + await ctx.syncAckAll(auth, response1); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + + // Delete all edits + await assetEditRepo.replaceAll(asset.id, []); + + const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response2).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + editId: edits[0].id, + }, + type: SyncEntityType.AssetEditDeleteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response2); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should only sync asset edits for own user', async () => { + const { auth, ctx } = await setup(); + const { user: user2 } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + const { session } = await ctx.newSession({ userId: user2.id }); + const auth2 = factory.auth({ session, user: user2 }); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + // User 2 should see their own edit + await expect(ctx.syncStream(auth2, [SyncRequestType.AssetEditsV1])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetEditV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + // User 1 should not see user 2's edit + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should sync edits for multiple assets', async () => { + const { auth, ctx } = await setup(); + const { asset: asset1 } = await ctx.newAsset({ ownerId: auth.user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset1.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + await assetEditRepo.replaceAll(asset2.id, [ + { + action: AssetEditAction.Rotate, + parameters: { angle: 270 }, + }, + ]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset1.id, + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset2.id, + action: AssetEditAction.Rotate, + parameters: { angle: 270 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should not sync edits for partner assets', async () => { + const { auth, ctx } = await setup(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: auth.user.id }); + const { asset } = await ctx.newAsset({ ownerId: partner.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + // Should not see partner's asset edits in own sync + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); +}); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index 8b4310e600..34a1e8e73c 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -97,3 +97,134 @@ describe(SyncEntityType.AssetFaceV1, () => { await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]); }); }); + +describe(SyncEntityType.AssetFaceV2, () => { + it('should detect and sync the first asset face', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetFace.id, + assetId: asset.id, + personId: person.id, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + }), + type: 'AssetFaceV2', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); + + it('should detect and sync a deleted asset face', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + await personRepo.deleteAssetFace(assetFace.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: 'AssetFaceDeleteV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); + + it('should not sync an asset face or asset face delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceV2 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + + await personRepo.deleteAssetFace(assetFace.id); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceDeleteV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); + + it('should contain the deletedAt and isVisible fields in AssetFaceV2', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + + let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetFace.id, + assetId: asset.id, + personId: person.id, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + deletedAt: null, + isVisible: true, + }), + type: 'AssetFaceV2', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + + await personRepo.deleteAssetFace(assetFace.id); + + response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: 'AssetFaceDeleteV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); +}); 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 Promise.resolve(`${input} (hashed)`)), - hashSha256: vitest.fn().mockImplementation((input) => `${input} (hashed)`), + hashSha256: vitest.fn().mockImplementation((input) => Buffer.from(`${input} (hashed)`)), verifySha256: vitest.fn().mockImplementation(() => true), hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index b6b1e82b52..bd8deb4b3a 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -12,6 +12,6 @@ export const newMediaRepositoryMock = (): Mocked = {}) => ({ updateId: newUuidV7(), deviceOS: 'android', deviceType: 'mobile', - token: 'abc123', + token: Buffer.from('abc123'), parentId: null, expiresAt: null, userId: newUuid(), diff --git a/server/test/sql-tools/check-constraint-default-name.stub.ts b/server/test/sql-tools/check-constraint-default-name.stub.ts deleted file mode 100644 index 1cb7c0644a..0000000000 --- a/server/test/sql-tools/check-constraint-default-name.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Check, Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -@Check({ expression: '1=1' }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create a check constraint with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.CHECK, - name: 'CHK_8d2ecfd49b984941f6b2589799', - tableName: 'table1', - expression: '1=1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/check-constraint-override-name.stub.ts b/server/test/sql-tools/check-constraint-override-name.stub.ts deleted file mode 100644 index 3752dcfb22..0000000000 --- a/server/test/sql-tools/check-constraint-override-name.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Check, Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -@Check({ name: 'CHK_test', expression: '1=1' }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create a check constraint with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: '1=1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-create-date.stub.ts b/server/test/sql-tools/column-create-date.stub.ts deleted file mode 100644 index db5add2a12..0000000000 --- a/server/test/sql-tools/column-create-date.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { CreateDateColumn, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @CreateDateColumn() - createdAt!: string; -} - -export const description = 'should register a table with an created at date column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'createdAt', - tableName: 'table1', - type: 'timestamp with time zone', - default: 'now()', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-array.stub.ts b/server/test/sql-tools/column-default-array.stub.ts deleted file mode 100644 index b5e9b7d04a..0000000000 --- a/server/test/sql-tools/column-default-array.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', array: true, default: [] }) - column1!: string[]; -} - -export const description = 'should register a table with a column with a default value (array)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: true, - primary: false, - synchronize: true, - default: "'{}'", - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-boolean.stub.ts b/server/test/sql-tools/column-default-boolean.stub.ts deleted file mode 100644 index 6454333599..0000000000 --- a/server/test/sql-tools/column-default-boolean.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'boolean', default: true }) - column1!: boolean; -} - -export const description = 'should register a table with a column with a default value (boolean)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'boolean', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: 'true', - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-date.stub.ts b/server/test/sql-tools/column-default-date.stub.ts deleted file mode 100644 index 70f4d520f9..0000000000 --- a/server/test/sql-tools/column-default-date.stub.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -const date = new Date(2023, 0, 1); - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: date }) - column1!: string; -} - -export const description = 'should register a table with a column with a default value (date)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: "'2023-01-01T00:00:00.000Z'", - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-function.stub.ts b/server/test/sql-tools/column-default-function.stub.ts deleted file mode 100644 index 1066a9af21..0000000000 --- a/server/test/sql-tools/column-default-function.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: () => 'now()' }) - column1!: string; -} - -export const description = 'should register a table with a column with a default function'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: 'now()', - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-null.stub.ts b/server/test/sql-tools/column-default-null.stub.ts deleted file mode 100644 index b517ca5a96..0000000000 --- a/server/test/sql-tools/column-default-null.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: null }) - column1!: string; -} - -export const description = 'should register a nullable column from a default of null'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-number.stub.ts b/server/test/sql-tools/column-default-number.stub.ts deleted file mode 100644 index 7954f2498b..0000000000 --- a/server/test/sql-tools/column-default-number.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'integer', default: 0 }) - column1!: string; -} - -export const description = 'should register a table with a column with a default value (number)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'integer', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: '0', - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-string.stub.ts b/server/test/sql-tools/column-default-string.stub.ts deleted file mode 100644 index 0d0a18a0eb..0000000000 --- a/server/test/sql-tools/column-default-string.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: 'foo' }) - column1!: string; -} - -export const description = 'should register a table with a column with a default value (string)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: "'foo'", - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-delete-date.stub.ts b/server/test/sql-tools/column-delete-date.stub.ts deleted file mode 100644 index de494ad16e..0000000000 --- a/server/test/sql-tools/column-delete-date.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DatabaseSchema, DeleteDateColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @DeleteDateColumn() - deletedAt!: string; -} - -export const description = 'should register a table with a deleted at date column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'deletedAt', - tableName: 'table1', - type: 'timestamp with time zone', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-enum-type.stub.ts b/server/test/sql-tools/column-enum-type.stub.ts deleted file mode 100644 index 563835d720..0000000000 --- a/server/test/sql-tools/column-enum-type.stub.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Column, DatabaseSchema, registerEnum, Table } from 'src/sql-tools'; - -enum Test { - Foo = 'foo', - Bar = 'bar', -} - -const test_enum = registerEnum({ name: 'test_enum', values: Object.values(Test) }); - -@Table() -export class Table1 { - @Column({ enum: test_enum }) - column1!: string; -} - -export const description = 'should accept an enum type'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [ - { - name: 'test_enum', - values: ['foo', 'bar'], - synchronize: true, - }, - ], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'enum', - enumName: 'test_enum', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-generated-identity.ts b/server/test/sql-tools/column-generated-identity.ts deleted file mode 100644 index 29f7ba969a..0000000000 --- a/server/test/sql-tools/column-generated-identity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryGeneratedColumn({ strategy: 'identity' }) - column1!: string; -} - -export const description = 'should register a table with a generated identity column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'integer', - identity: true, - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_50c4f9905061b1e506d38a2a380', - tableName: 'table1', - columnNames: ['column1'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-generated-uuid.stub.ts b/server/test/sql-tools/column-generated-uuid.stub.ts deleted file mode 100644 index 0d4d78a84f..0000000000 --- a/server/test/sql-tools/column-generated-uuid.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryGeneratedColumn({ strategy: 'uuid' }) - column1!: string; -} - -export const description = 'should register a table with a primary generated uuid column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'uuid', - default: 'uuid_generate_v4()', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_50c4f9905061b1e506d38a2a380', - tableName: 'table1', - columnNames: ['column1'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-index-name-default.ts b/server/test/sql-tools/column-index-name-default.ts deleted file mode 100644 index ea1fb17fb4..0000000000 --- a/server/test/sql-tools/column-index-name-default.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ index: true }) - column1!: string; -} - -export const description = 'should create a column with an index'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_50c4f9905061b1e506d38a2a38', - columnNames: ['column1'], - tableName: 'table1', - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-index-name.ts b/server/test/sql-tools/column-index-name.ts deleted file mode 100644 index 2a37469600..0000000000 --- a/server/test/sql-tools/column-index-name.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ indexName: 'IDX_test' }) - column1!: string; -} - -export const description = 'should create a column with an index if a name is provided'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_test', - columnNames: ['column1'], - tableName: 'table1', - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-inferred-nullable.stub.ts b/server/test/sql-tools/column-inferred-nullable.stub.ts deleted file mode 100644 index 50810291d3..0000000000 --- a/server/test/sql-tools/column-inferred-nullable.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ default: null }) - column1!: string; -} - -export const description = 'should infer nullable from the default value'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-name-default.stub.ts b/server/test/sql-tools/column-name-default.stub.ts deleted file mode 100644 index 57e15fc8b6..0000000000 --- a/server/test/sql-tools/column-name-default.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column() - column1!: string; -} - -export const description = 'should register a table with a column with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-name-override.stub.ts b/server/test/sql-tools/column-name-override.stub.ts deleted file mode 100644 index 8741162735..0000000000 --- a/server/test/sql-tools/column-name-override.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ name: 'column-1' }) - column1!: string; -} - -export const description = 'should register a table with a column with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column-1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-name-string.stub.ts b/server/test/sql-tools/column-name-string.stub.ts deleted file mode 100644 index e4a60f51b9..0000000000 --- a/server/test/sql-tools/column-name-string.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column('column-1') - column1!: string; -} - -export const description = 'should register a table with a column with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column-1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-nullable.stub.ts b/server/test/sql-tools/column-nullable.stub.ts deleted file mode 100644 index 31c72fe97c..0000000000 --- a/server/test/sql-tools/column-nullable.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ nullable: true }) - column1!: string; -} - -export const description = 'should set nullable correctly'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-string-length.stub.ts b/server/test/sql-tools/column-string-length.stub.ts deleted file mode 100644 index a04cfbd117..0000000000 --- a/server/test/sql-tools/column-string-length.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ length: 2 }) - column1!: string; -} - -export const description = 'should use create a string column with a fixed length'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - length: 2, - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts deleted file mode 100644 index 076a93bf57..0000000000 --- a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'uuid', unique: true }) - id!: string; -} - -export const description = 'should create a unique key constraint with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts deleted file mode 100644 index d4c3d5bb6a..0000000000 --- a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'uuid', unique: true, uniqueConstraintName: 'UQ_test' }) - id!: string; -} - -export const description = 'should create a unique key constraint with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-update-date.stub.ts b/server/test/sql-tools/column-update-date.stub.ts deleted file mode 100644 index dfa09888c0..0000000000 --- a/server/test/sql-tools/column-update-date.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { DatabaseSchema, Table, UpdateDateColumn } from 'src/sql-tools'; - -@Table() -export class Table1 { - @UpdateDateColumn() - updatedAt!: string; -} - -export const description = 'should register a table with an updated at date column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'updatedAt', - tableName: 'table1', - type: 'timestamp with time zone', - default: 'now()', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/errors/table-duplicate-decorator.stub.ts b/server/test/sql-tools/errors/table-duplicate-decorator.stub.ts deleted file mode 100644 index 3b7a8781b9..0000000000 --- a/server/test/sql-tools/errors/table-duplicate-decorator.stub.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Table } from 'src/sql-tools'; - -@Table({ name: 'table-1' }) -@Table({ name: 'table-2' }) -export class Table1 {} - -export const message = 'Table table-2 has already been registered'; diff --git a/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts b/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts deleted file mode 100644 index 2523701e49..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id1!: string; - - @PrimaryColumn({ type: 'uuid' }) - id2!: string; -} - -@Table() -@ForeignKeyConstraint({ - columns: ['parentId1', 'parentId2'], - referenceTable: () => Table1, - referenceColumns: ['id2', 'id1'], -}) -export class Table2 { - @Column({ type: 'uuid' }) - parentId1!: string; - - @Column({ type: 'uuid' }) - parentId2!: string; -} - -export const description = 'should create a foreign key constraint to the target table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id1', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - { - name: 'id2', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_e457e8b1301b7bc06ef78188ee4', - tableName: 'table1', - columnNames: ['id1', 'id2'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId1', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - { - name: 'parentId2', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_aed36d04470eba20161aa8b1dc', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_aed36d04470eba20161aa8b1dc6', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - referenceColumnNames: ['id2', 'id1'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts deleted file mode 100644 index dcd957676a..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId2'], referenceTable: () => Table1 }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should warn against missing column in foreign key constraint'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: ['[@ForeignKeyConstraint.columns] Unable to find column (Table2.parentId2)'], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts deleted file mode 100644 index 238f4174f3..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, referenceColumns: ['foo'] }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should warn against missing reference column in foreign key constraint'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: ['[@ForeignKeyConstraint.referenceColumns] Unable to find column (Table1.foo)'], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts deleted file mode 100644 index c6d6fd5b09..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Column, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools'; - -class Foo {} - -@Table() -@ForeignKeyConstraint({ - columns: ['parentId'], - referenceTable: () => Foo, -}) -export class Table1 { - @Column() - parentId!: string; -} - -export const description = 'should warn against missing reference table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'parentId', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: ['[@ForeignKeyConstraint.referenceTable] Unable to find table (Foo)'], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts b/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts deleted file mode 100644 index a86611bb50..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id1!: string; - - @PrimaryColumn({ type: 'uuid' }) - id2!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId1', 'parentId2'], referenceTable: () => Table1 }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId1!: string; - - @Column({ type: 'uuid' }) - parentId2!: string; -} - -export const description = 'should create a foreign key constraint to the target table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id1', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - { - name: 'id2', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_e457e8b1301b7bc06ef78188ee4', - tableName: 'table1', - columnNames: ['id1', 'id2'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId1', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - { - name: 'parentId2', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_aed36d04470eba20161aa8b1dc', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_aed36d04470eba20161aa8b1dc6', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - referenceColumnNames: ['id1', 'id2'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts b/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts deleted file mode 100644 index 8bb436c9ac..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, index: false }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should create a foreign key constraint to the target table without an index'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts b/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts deleted file mode 100644 index 6680b13b91..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column() - foo!: string; -} - -@Table() -@ForeignKeyConstraint({ - columns: ['bar'], - referenceTable: () => Table1, - referenceColumns: ['foo'], -}) -export class Table2 { - @Column() - bar!: string; -} - -export const description = 'should create a foreign key constraint to the target table without a primary key'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'foo', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'bar', - tableName: 'table2', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_7d9c784c98d12365d198d52e4e', - tableName: 'table2', - columnNames: ['bar'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_7d9c784c98d12365d198d52e4e6', - tableName: 'table2', - columnNames: ['bar'], - referenceTableName: 'table1', - referenceColumnNames: ['foo'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint.stub.ts b/server/test/sql-tools/foreign-key-constraint.stub.ts deleted file mode 100644 index 518c5aa6bb..0000000000 --- a/server/test/sql-tools/foreign-key-constraint.stub.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1 }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should create a foreign key constraint to the target table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_3fcca5cc563abf256fc346e3ff', - tableName: 'table2', - columnNames: ['parentId'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-inferred-type.stub.ts b/server/test/sql-tools/foreign-key-inferred-type.stub.ts deleted file mode 100644 index 33f1c2dfde..0000000000 --- a/server/test/sql-tools/foreign-key-inferred-type.stub.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { ConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -export class Table2 { - @ForeignKeyColumn(() => Table1, {}) - parentId!: string; -} - -export const description = 'should infer the column type from the reference column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_3fcca5cc563abf256fc346e3ff', - tableName: 'table2', - columnNames: ['parentId'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts deleted file mode 100644 index 288f7c6698..0000000000 --- a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -export class Table2 { - @ForeignKeyColumn(() => Table1, { unique: true }) - parentId!: string; -} - -export const description = 'should create a foreign key constraint with a unique constraint'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_3fcca5cc563abf256fc346e3ff', - tableName: 'table2', - columnNames: ['parentId'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - { - type: ConstraintType.UNIQUE, - name: 'UQ_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-name-default.stub.ts b/server/test/sql-tools/index-name-default.stub.ts deleted file mode 100644 index 1918106eaa..0000000000 --- a/server/test/sql-tools/index-name-default.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create an index with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_b249cc64cf63b8a22557cdc853', - tableName: 'table1', - unique: false, - columnNames: ['id'], - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-name-override.stub.ts b/server/test/sql-tools/index-name-override.stub.ts deleted file mode 100644 index a48dc6e6d6..0000000000 --- a/server/test/sql-tools/index-name-override.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ name: 'IDX_test', columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create an index with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_test', - tableName: 'table1', - unique: false, - columnNames: ['id'], - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-with-expression.ts b/server/test/sql-tools/index-with-expression.ts deleted file mode 100644 index 07755b7f96..0000000000 --- a/server/test/sql-tools/index-with-expression.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ expression: '"id" IS NOT NULL' }) -export class Table1 { - @Column({ nullable: true }) - column1!: string; -} - -export const description = 'should create an index based off of an expression'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_376788d186160c4faa5aaaef63', - tableName: 'table1', - unique: false, - expression: '"id" IS NOT NULL', - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-with-where.stub.ts b/server/test/sql-tools/index-with-where.stub.ts deleted file mode 100644 index 86a4a3089d..0000000000 --- a/server/test/sql-tools/index-with-where.stub.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ columns: ['id'], where: '"id" IS NOT NULL' }) -export class Table1 { - @Column({ nullable: true }) - column1!: string; -} - -export const description = 'should create an index with a where clause'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_9f4e073964c0395f51f9b39900', - tableName: 'table1', - unique: false, - columnNames: ['id'], - where: '"id" IS NOT NULL', - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts deleted file mode 100644 index 7edfd6ff36..0000000000 --- a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a primary key constraint to the table with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts deleted file mode 100644 index ce1f2a096c..0000000000 --- a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table({ primaryConstraintName: 'PK_test' }) -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a primary key constraint to the table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/table-name-default.stub.ts b/server/test/sql-tools/table-name-default.stub.ts deleted file mode 100644 index 4384944364..0000000000 --- a/server/test/sql-tools/table-name-default.stub.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 {} - -export const description = 'should register a table with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/table-name-override.stub.ts b/server/test/sql-tools/table-name-override.stub.ts deleted file mode 100644 index 5bccc429d0..0000000000 --- a/server/test/sql-tools/table-name-override.stub.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DatabaseSchema, Table } from 'src/sql-tools'; - -@Table({ name: 'table-1' }) -export class Table1 {} - -export const description = 'should register a table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table-1', - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/table-name-string-option.stub.ts b/server/test/sql-tools/table-name-string-option.stub.ts deleted file mode 100644 index f394699172..0000000000 --- a/server/test/sql-tools/table-name-string-option.stub.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DatabaseSchema, Table } from 'src/sql-tools'; - -@Table('table-1') -export class Table1 {} - -export const description = 'should register a table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table-1', - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-after-delete.stub.ts b/server/test/sql-tools/trigger-after-delete.stub.ts deleted file mode 100644 index dcceaf25ce..0000000000 --- a/server/test/sql-tools/trigger-after-delete.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { AfterDeleteTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; - -const test_fn = registerFunction({ - name: 'test_fn', - body: 'SELECT 1;', - returnType: 'character varying', -}); - -@Table() -@AfterDeleteTrigger({ - name: 'my_trigger', - function: test_fn, - scope: 'row', -}) -export class Table1 {} - -export const description = 'should create a trigger'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [expect.any(Object)], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'my_trigger', - functionName: 'test_fn', - tableName: 'table1', - timing: 'after', - scope: 'row', - actions: ['delete'], - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-before-update.stub.ts b/server/test/sql-tools/trigger-before-update.stub.ts deleted file mode 100644 index 6bf6afc721..0000000000 --- a/server/test/sql-tools/trigger-before-update.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BeforeUpdateTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; - -const test_fn = registerFunction({ - name: 'test_fn', - body: 'SELECT 1;', - returnType: 'character varying', -}); - -@Table() -@BeforeUpdateTrigger({ - name: 'my_trigger', - function: test_fn, - scope: 'row', -}) -export class Table1 {} - -export const description = 'should create a trigger '; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [expect.any(Object)], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'my_trigger', - functionName: 'test_fn', - tableName: 'table1', - timing: 'before', - scope: 'row', - actions: ['update'], - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-name-default.stub.ts b/server/test/sql-tools/trigger-name-default.stub.ts deleted file mode 100644 index 382389bcf7..0000000000 --- a/server/test/sql-tools/trigger-name-default.stub.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; - -@Table() -@Trigger({ - timing: 'before', - actions: ['insert'], - scope: 'row', - functionName: 'function1', -}) -export class Table1 {} - -export const description = 'should register a trigger with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'TR_ca71832b10b77ed600ef05df631', - tableName: 'table1', - functionName: 'function1', - actions: ['insert'], - scope: 'row', - timing: 'before', - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-name-override.stub.ts b/server/test/sql-tools/trigger-name-override.stub.ts deleted file mode 100644 index 33c4da6b67..0000000000 --- a/server/test/sql-tools/trigger-name-override.stub.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; - -@Table() -@Trigger({ - name: 'trigger1', - timing: 'before', - actions: ['insert'], - scope: 'row', - functionName: 'function1', -}) -export class Table1 {} - -export const description = 'should a trigger with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'trigger1', - tableName: 'table1', - functionName: 'function1', - actions: ['insert'], - scope: 'row', - timing: 'before', - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/unique-constraint-name-default.stub.ts b/server/test/sql-tools/unique-constraint-name-default.stub.ts deleted file mode 100644 index 90fbe09224..0000000000 --- a/server/test/sql-tools/unique-constraint-name-default.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools'; - -@Table() -@Unique({ columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a unique constraint to the table with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/unique-constraint-name-override.stub.ts b/server/test/sql-tools/unique-constraint-name-override.stub.ts deleted file mode 100644 index 3da7584c0c..0000000000 --- a/server/test/sql-tools/unique-constraint-name-override.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools'; - -@Table() -@Unique({ name: 'UQ_test', columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a unique constraint to the table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/utils.ts b/server/test/utils.ts index c2a83c52ae..b3e47b2b7e 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -1,3 +1,4 @@ +import { createPostgres, DatabaseConnectionParams } from '@immich/sql-tools'; import { CallHandler, ExecutionContext, Provider, ValidationPipe } from '@nestjs/common'; import { APP_GUARD, APP_PIPE } from '@nestjs/core'; import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils'; @@ -9,7 +10,6 @@ import multer from 'multer'; import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Duplex, Readable, Writable } from 'node:stream'; import { PNG } from 'pngjs'; -import postgres from 'postgres'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { AuthGuard } from 'src/middleware/auth.guard'; @@ -70,7 +70,7 @@ import { DB } from 'src/schema'; import { AuthService } from 'src/services/auth.service'; import { BaseService } from 'src/services/base.service'; import { RepositoryInterface } from 'src/types'; -import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database'; +import { getKyselyConfig } from 'src/utils/database'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; @@ -445,13 +445,8 @@ const withDatabase = (url: string, name: string) => url.replace(`/${templateName export const getKyselyDB = async (suffix?: string): Promise> => { const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!; - const sql = postgres({ - ...asPostgresConnectionConfig({ - connectionType: 'url', - url: withDatabase(testUrl, 'postgres'), - }), - max: 1, - }); + const connection = { connectionType: 'url', url: withDatabase(testUrl, 'postgres') } as DatabaseConnectionParams; + const sql = createPostgres({ maxConnections: 1, connection }); const randomSuffix = Math.random().toString(36).slice(2, 7); const dbName = `immich_${suffix ?? randomSuffix}`; diff --git a/server/tsconfig.json b/server/tsconfig.json index e12b614f0d..fcb0ea2a97 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "node16", + "module": "node20", "strict": true, "declaration": true, "removeComments": true, 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 e172584c5d..03ccb35d7e 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.1", - "@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", @@ -40,7 +40,7 @@ "@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", @@ -65,7 +65,7 @@ "uplot": "^1.6.32" }, "devDependencies": { - "@eslint/js": "^9.36.0", + "@eslint/js": "^10.0.0", "@faker-js/faker": "^10.0.0", "@koddsson/eslint-plugin-tscompat": "^0.2.0", "@socket.io/component-emitter": "^3.1.0", @@ -85,20 +85,20 @@ "@types/qrcode": "^1.5.5", "@vitest/coverage-v8": "^3.0.0", "dotenv": "^17.0.0", - "eslint": "^9.36.0", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-compat": "^6.0.2", "eslint-plugin-svelte": "^3.12.4", - "eslint-plugin-unicorn": "^62.0.0", + "eslint-plugin-unicorn": "^63.0.0", "factory.ts": "^1.4.1", - "globals": "^16.0.0", + "globals": "^17.0.0", "happy-dom": "^20.0.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.50.0", + "svelte": "5.53.0", "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/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/Image.spec.ts b/web/src/lib/components/Image.spec.ts new file mode 100644 index 0000000000..8435e1bb25 --- /dev/null +++ b/web/src/lib/components/Image.spec.ts @@ -0,0 +1,87 @@ +import Image from '$lib/components/Image.svelte'; +import { cancelImageUrl } from '$lib/utils/sw-messaging'; +import { fireEvent, render } from '@testing-library/svelte'; + +vi.mock('$lib/utils/sw-messaging', () => ({ + cancelImageUrl: vi.fn(), +})); + +describe('Image component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders an img element when src is provided', () => { + const { baseElement } = render(Image, { src: '/test.jpg', alt: 'test' }); + const img = baseElement.querySelector('img'); + expect(img).not.toBeNull(); + expect(img!.getAttribute('src')).toBe('/test.jpg'); + }); + + it('does not render an img element when src is undefined', () => { + const { baseElement } = render(Image, { src: undefined }); + const img = baseElement.querySelector('img'); + expect(img).toBeNull(); + }); + + it('calls onStart when src is set', () => { + const onStart = vi.fn(); + render(Image, { src: '/test.jpg', onStart }); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it('calls onLoad when image loads', async () => { + const onLoad = vi.fn(); + const { baseElement } = render(Image, { src: '/test.jpg', onLoad }); + const img = baseElement.querySelector('img')!; + await fireEvent.load(img); + expect(onLoad).toHaveBeenCalledOnce(); + }); + + it('calls onError when image fails to load', async () => { + const onError = vi.fn(); + const { baseElement } = render(Image, { src: '/test.jpg', onError }); + const img = baseElement.querySelector('img')!; + await fireEvent.error(img); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(onError.mock.calls[0][0].message).toBe('Failed to load image: /test.jpg'); + }); + + it('calls cancelImageUrl on unmount', () => { + const { unmount } = render(Image, { src: '/test.jpg' }); + expect(cancelImageUrl).not.toHaveBeenCalled(); + unmount(); + expect(cancelImageUrl).toHaveBeenCalledWith('/test.jpg'); + }); + + it('does not call onLoad after unmount', async () => { + const onLoad = vi.fn(); + const { baseElement, unmount } = render(Image, { src: '/test.jpg', onLoad }); + const img = baseElement.querySelector('img')!; + unmount(); + await fireEvent.load(img); + expect(onLoad).not.toHaveBeenCalled(); + }); + + it('does not call onError after unmount', async () => { + const onError = vi.fn(); + const { baseElement, unmount } = render(Image, { src: '/test.jpg', onError }); + const img = baseElement.querySelector('img')!; + unmount(); + await fireEvent.error(img); + expect(onError).not.toHaveBeenCalled(); + }); + + it('passes through additional HTML attributes', () => { + const { baseElement } = render(Image, { + src: '/test.jpg', + alt: 'test alt', + class: 'my-class', + draggable: false, + }); + const img = baseElement.querySelector('img')!; + expect(img.getAttribute('alt')).toBe('test alt'); + expect(img.getAttribute('draggable')).toBe('false'); + }); +}); diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte new file mode 100644 index 0000000000..801a466ca8 --- /dev/null +++ b/web/src/lib/components/Image.svelte @@ -0,0 +1,54 @@ + + +{#if capturedSource} + {#key capturedSource} + + {/key} +{/if} 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..21077c63ae 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/editor/transform-tool/transform-tool.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte index 62c5c771a9..ed9f854bb9 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte @@ -42,16 +42,7 @@ } function ratioSelected(ratio: AspectRatioOption): boolean { - let currentRatioRotated; - if (ratio.value === 'original') { - const { width, height } = transformManager.cropImageSize; - // Account for rotation when comparing to original - if (isRotated) { - currentRatioRotated = `${height}:${width}`; - } - currentRatioRotated = `${width}:${height}`; - } - currentRatioRotated = rotatedRatio(ratio); + const currentRatioRotated = rotatedRatio(ratio); return transformManager.cropAspectRatio === currentRatioRotated; } 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 7c3ab2eb21..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(() => { @@ -226,7 +229,7 @@ alt={$getAltText(toTimelineAsset(asset))} class="h-full w-full {$slideshowState === SlideshowState.None ? 'object-contain' - : slideshowLookCssMapping[$slideshowLook]} checkerboard" + : slideshowLookCssMapping[$slideshowLook]}" draggable="false" /> @@ -259,8 +262,4 @@ visibility: hidden; animation: 0s linear 0.4s forwards delayedVisibility; } - .checkerboard { - background-image: conic-gradient(#808080 25%, #b0b0b0 25% 50%, #808080 50% 75%, #b0b0b0 75%); - background-size: 20px 20px; - } diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index a15a787e64..f66e80ef6d 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -2,9 +2,10 @@ import { Icon } from '@immich/ui'; import { mdiImageBrokenVariant } from '@mdi/js'; import { t } from 'svelte-i18n'; + import type { ClassValue } from 'svelte/elements'; interface Props { - class?: string; + class?: ClassValue; hideMessage?: boolean; width?: string | undefined; height?: string | undefined; @@ -14,7 +15,10 @@
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts new file mode 100644 index 0000000000..04835e9209 --- /dev/null +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts @@ -0,0 +1,89 @@ +import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; +import { fireEvent, render } from '@testing-library/svelte'; + +vi.mock('$lib/utils/sw-messaging', () => ({ + cancelImageUrl: vi.fn(), +})); + +describe('ImageThumbnail component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders an img element with correct attributes', () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + }); + const img = baseElement.querySelector('img'); + expect(img).not.toBeNull(); + expect(img!.getAttribute('src')).toBe('/test-thumbnail.jpg'); + expect(img!.getAttribute('alt')).toBe(''); + }); + + it('shows BrokenAsset on error', async () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + }); + const img = baseElement.querySelector('img')!; + await fireEvent.error(img); + + expect(baseElement.querySelector('img')).toBeNull(); + expect(baseElement.querySelector('span')?.textContent).toEqual('error_loading_image'); + }); + + it('calls onComplete with false on successful load', async () => { + const onComplete = vi.fn(); + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + onComplete, + }); + const img = baseElement.querySelector('img')!; + await fireEvent.load(img); + expect(onComplete).toHaveBeenCalledWith(false); + }); + + it('calls onComplete with true on error', async () => { + const onComplete = vi.fn(); + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + onComplete, + }); + const img = baseElement.querySelector('img')!; + await fireEvent.error(img); + expect(onComplete).toHaveBeenCalledWith(true); + }); + + it('applies hidden styles when hidden is true', () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + hidden: true, + }); + const img = baseElement.querySelector('img')!; + const style = img.getAttribute('style') ?? ''; + expect(style).toContain('grayscale'); + expect(style).toContain('opacity'); + }); + + it('sets alt text after loading', async () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + }); + const img = baseElement.querySelector('img')!; + expect(img.getAttribute('alt')).toBe(''); + + await fireEvent.load(img); + expect(img.getAttribute('alt')).toBe('Test image'); + }); +}); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index a1dd22f44f..a54ad911fd 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,9 +1,8 @@ {#if errored} - + {:else} - {loaded {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 8270646470..2b5e9cdf93 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -196,13 +196,19 @@ document.removeEventListener('pointermove', moveHandler, true); }; }); + const backgroundColorClass = $derived.by(() => { + if (loaded && !selected) { + return 'bg-transparent'; + } + if (disabled) { + return 'bg-gray-300'; + } + return 'dark:bg-neutral-700 bg-neutral-200'; + });
- -
-
- -
- - {#if !usingMobileDevice && !disabled} -
- {/if} - - - {#if dimmed && !mouseOver} -
- {/if} - - - {#if !authManager.isSharedLink && asset.isFavorite} -
- -
- {/if} - - {#if !!assetOwner} -
-

- {assetOwner.name} -

-
- {/if} - - {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} -
- -
- {/if} - - {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR} -
- - - -
- {/if} - - {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} -
- - - -
- {/if} - - - {#if asset.stack && showStackedIcon} -
- -

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

- -
-
- {/if} -
- - - {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver} - evt.preventDefault()} - tabindex={-1} - aria-label="Thumbnail URL" - > - - {/if} - ((loaded = true), (thumbError = errored))} /> {#if asset.isVideo} -
+
{:else if asset.isImage && asset.livePhotoVideoId} -
+
{/if} + + +
+ + {#if !usingMobileDevice && !disabled} +
+ {/if} + + + {#if dimmed && !mouseOver} +
+ {/if} + + + {#if !authManager.isSharedLink && asset.isFavorite} +
+ +
+ {/if} + + {#if !!assetOwner} +
+

+ {assetOwner.name} +

+
+ {/if} + + {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} +
+ +
+ {/if} + + {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR} +
+ + + +
+ {/if} + + {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} +
+ + + +
+ {/if} + + + {#if asset.stack && showStackedIcon} +
+ +

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

+ +
+
+ {/if} +
+ + + {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver} + evt.preventDefault()} + tabindex={-1} + aria-label="Thumbnail URL" + > + + {/if}
{#if selectionCandidate}
@@ -410,7 +418,7 @@
- - diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 222fa7a8ec..28b7ef62ff 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -2,6 +2,7 @@ import { Icon, LoadingSpinner } from '@immich/ui'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import { Duration } from 'luxon'; + import type { ClassValue } from 'svelte/elements'; interface Props { url: string; @@ -12,6 +13,7 @@ curve?: boolean; playIcon?: string; pauseIcon?: string; + class?: ClassValue; } let { @@ -23,6 +25,7 @@ curve = false, playIcon = mdiPlayCircleOutline, pauseIcon = mdiPauseCircleOutline, + class: className = undefined, }: Props = $props(); let remainingSeconds = $state(durationInSeconds); @@ -57,7 +60,7 @@ {#if enablePlayback}