diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 10dc88088f..239a448bf6 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -30,18 +30,6 @@ on: required: true IOS_CERTIFICATE_PASSWORD: required: true - IOS_PROVISIONING_PROFILE: - required: true - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: - required: true - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: - required: true - IOS_DEVELOPMENT_PROVISIONING_PROFILE: - required: true - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: - required: true - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: - required: true FASTLANE_TEAM_ID: required: true pull_request: @@ -96,7 +84,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false @@ -115,7 +103,7 @@ jobs: - name: Restore Gradle Cache id: cache-gradle-restore - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: | ~/.gradle/caches @@ -165,14 +153,14 @@ jobs: fi - name: Publish Android Artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: release-apk-signed path: mobile/build/app/outputs/flutter-apk/*.apk - name: Save Gradle Cache id: cache-gradle-save - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 if: github.ref == 'refs/heads/main' with: path: | @@ -194,7 +182,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false @@ -240,35 +228,14 @@ jobs: mkdir -p ~/.appstoreconnect/private_keys echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8 - - name: Import Certificate and Provisioning Profiles + - name: Import Certificate env: IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - ENVIRONMENT: ${{ inputs.environment || 'development' }} working-directory: ./mobile/ios run: | # Decode certificate echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12 - # Decode provisioning profiles based on environment - if [[ "$ENVIRONMENT" == "development" ]]; then - echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision - echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision - echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision - ls -lh profile_dev*.mobileprovision - else - echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision - echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision - echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision - ls -lh profile*.mobileprovision - fi - - name: Create keychain and import certificate env: KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} @@ -319,7 +286,7 @@ jobs: security delete-keychain build.keychain || true - name: Upload IPA artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ios-release-ipa path: mobile/ios/Runner.ipa diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml index a75770ec49..55f91e7989 100644 --- a/.github/workflows/cache-cleanup.yml +++ b/.github/workflows/cache-cleanup.yml @@ -25,7 +25,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check out code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 9bdf1ecb89..db7ca0f57b 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -35,7 +35,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -78,7 +78,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7e1f52b4db..71b5968960 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 680cd0318c..91916e4ed2 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -60,10 +60,11 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 @@ -85,7 +86,7 @@ jobs: run: pnpm build - name: Upload build output - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: docs-build-output path: docs/build/ diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 3a0e918812..1933b9d572 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -125,13 +125,13 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 - name: Load parameters id: parameters diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 643c35b1af..80cc17d32b 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -23,13 +23,13 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 - name: Destroy Docs Subdomain env: diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index f77ca48b41..11a9ef06e4 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.ref }} token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index dd6504bdd2..1a4c2b7945 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -56,7 +56,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true @@ -136,13 +136,13 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false - name: Download APK - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: release-apk-signed github-token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 64feb4f2d4..3ee96c45b7 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,7 +23,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true @@ -159,7 +159,7 @@ jobs: - name: Create PR id: create-pr - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.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 cb64cd37cf..30783f5e9b 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false @@ -74,7 +74,7 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Download APK - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: release-apk-signed github-token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 9c70922df1..2446b5ffcd 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -22,7 +22,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 2b72ceb40a..c0d53388c6 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -55,7 +55,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b00612b41b..2aed8c6da2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -114,7 +114,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -161,7 +161,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -203,7 +203,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -247,7 +247,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -285,7 +285,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -333,7 +333,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -379,7 +379,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false submodules: 'recursive' @@ -418,7 +418,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false submodules: 'recursive' @@ -473,7 +473,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false submodules: 'recursive' @@ -505,7 +505,7 @@ jobs: run: npx playwright test if: ${{ !cancelled() }} - name: Archive test results - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: success() || failure() with: name: e2e-web-test-results-${{ matrix.runner }} @@ -534,7 +534,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -566,17 +566,14 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 - - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) with: python-version: 3.11 - #cache: 'uv' - name: Install dependencies run: | uv sync --extra cpu @@ -610,7 +607,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -639,7 +636,7 @@ jobs: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -661,7 +658,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -723,7 +720,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/docs/docs/features/sharing.md b/docs/docs/features/sharing.md index c19b4f48e1..a884884bee 100644 --- a/docs/docs/features/sharing.md +++ b/docs/docs/features/sharing.md @@ -33,7 +33,7 @@ You can create a public link to share a group of photos or videos, or an album, The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance. ``` -https://immich.yourdomain.com/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k +https://my.immich.app/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k ``` ### Creating a public share link diff --git a/e2e-auth-server/Dockerfile b/e2e-auth-server/Dockerfile new file mode 100644 index 0000000000..aa7527c483 --- /dev/null +++ b/e2e-auth-server/Dockerfile @@ -0,0 +1,6 @@ +FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 +RUN corepack enable +ADD package.json *.ts ./ +RUN pnpm install +EXPOSE 2286 +CMD ["pnpm", "run", "start"] diff --git a/e2e/src/setup/auth-server.ts b/e2e-auth-server/auth-server.ts similarity index 96% rename from e2e/src/setup/auth-server.ts rename to e2e-auth-server/auth-server.ts index 489bda2ee4..a190ecd023 100644 --- a/e2e/src/setup/auth-server.ts +++ b/e2e-auth-server/auth-server.ts @@ -125,7 +125,7 @@ const setup = async () => { ], }); - const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`); + const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`); const app = oidc.listen(port, host, onStart); return () => app.close(); }; diff --git a/e2e-auth-server/package.json b/e2e-auth-server/package.json new file mode 100644 index 0000000000..73ede1b7c4 --- /dev/null +++ b/e2e-auth-server/package.json @@ -0,0 +1,15 @@ +{ + "name": "@immich/e2e-auth-server", + "version": "0.1.0", + "type": "module", + "main": "auth-server.ts", + "scripts": { + "start": "tsx startup.ts" + }, + "devDependencies": { + "jose": "^5.6.3", + "@types/oidc-provider": "^9.0.0", + "oidc-provider": "^9.0.0", + "tsx": "^4.20.6" + } +} diff --git a/e2e-auth-server/startup.ts b/e2e-auth-server/startup.ts new file mode 100644 index 0000000000..442cf6dfc2 --- /dev/null +++ b/e2e-auth-server/startup.ts @@ -0,0 +1,8 @@ +import setup from './auth-server' + +const teardown = await setup() +process.on('exit', () => { + teardown() + console.log('[e2e-auth-server] stopped') + process.exit(0) +}) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 867a367d54..a33cb6573c 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,6 +1,12 @@ name: immich-e2e services: + e2e-auth-server: + build: + context: ../e2e-auth-server + ports: + - 2286:2286 + immich-server: container_name: immich-e2e-server image: immich-server:latest @@ -27,8 +33,6 @@ services: - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true volumes: - ./test-assets:/test-assets - extra_hosts: - - 'auth-server:host-gateway' depends_on: redis: condition: service_started diff --git a/e2e/package.json b/e2e/package.json index 909556aa99..c42bf6eddb 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,12 +22,12 @@ "@eslint/js": "^9.8.0", "@faker-js/faker": "^10.1.0", "@immich/cli": "file:../cli", + "@immich/e2e-auth-server": "file:../e2e-auth-server", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", "@types/node": "^24.10.4", - "@types/oidc-provider": "^9.0.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", @@ -38,9 +38,7 @@ "eslint-plugin-unicorn": "^62.0.0", "exiftool-vendored": "^34.3.0", "globals": "^16.0.0", - "jose": "^5.6.3", "luxon": "^3.4.4", - "oidc-provider": "^9.0.0", "pg": "^8.11.3", "pngjs": "^7.0.0", "prettier": "^3.7.4", diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts index 58fc43a2d5..cbd68c003a 100644 --- a/e2e/src/api/specs/oauth.e2e-spec.ts +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -1,3 +1,4 @@ +import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server'; import { LoginResponseDto, SystemConfigOAuthDto, @@ -8,13 +9,12 @@ import { } from '@immich/sdk'; import { createHash, randomBytes } from 'node:crypto'; import { errorDto } from 'src/responses'; -import { OAuthClient, OAuthUser } from 'src/setup/auth-server'; import { app, asBearerAuth, baseUrl, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; const authServer = { - internal: 'http://auth-server:2286', + internal: 'http://e2e-auth-server:2286', external: 'http://127.0.0.1:2286', }; diff --git a/e2e/src/generators.ts b/e2e/src/generators.ts index c87427ceab..5e4895d708 100644 --- a/e2e/src/generators.ts +++ b/e2e/src/generators.ts @@ -26,6 +26,5 @@ export const makeRandomImage = () => { if (!value) { throw new Error('Ran out of random asset data'); } - return value; }; diff --git a/e2e/src/mock-network/timeline-network.ts b/e2e/src/mock-network/timeline-network.ts index 59bce71dd8..8780409657 100644 --- a/e2e/src/mock-network/timeline-network.ts +++ b/e2e/src/mock-network/timeline-network.ts @@ -1,3 +1,4 @@ +import { AssetResponseDto } from '@immich/sdk'; import { BrowserContext, Page, Request, Route } from '@playwright/test'; import { basename } from 'node:path'; import { @@ -63,15 +64,33 @@ export const setupTimelineMockApiRoutes = async ( }); await context.route('**/api/assets/*', async (route, request) => { - const url = new URL(request.url()); - const pathname = url.pathname; - const assetId = basename(pathname); - const asset = getAsset(timelineRestData, assetId); - return route.fulfill({ - status: 200, - contentType: 'application/json', - json: asset, - }); + if (request.method() === 'GET') { + const url = new URL(request.url()); + const pathname = url.pathname; + const assetId = basename(pathname); + let asset = getAsset(timelineRestData, assetId); + if (changes.assetDeletions.includes(asset!.id)) { + asset = { + ...asset, + isTrashed: true, + } as AssetResponseDto; + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: asset, + }); + } + await route.fallback(); + }); + + await context.route('**/api/assets', async (route, request) => { + if (request.method() === 'DELETE') { + return route.fulfill({ + status: 204, + }); + } + await route.fallback(); }); await context.route('**/api/assets/*/ocr', async (route) => { @@ -117,17 +136,28 @@ export const setupTimelineMockApiRoutes = async ( }); await context.route('**/api/albums/**', async (route, request) => { - const pattern = /\/api\/albums\/(?[^/?]+)/; - const match = request.url().match(pattern); - if (!match) { - return route.continue(); + const albumsMatch = request.url().match(/\/api\/albums\/(?[^/?]+)/); + if (albumsMatch) { + const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes); + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: album, + }); } - const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes); - return route.fulfill({ - status: 200, - contentType: 'application/json', - json: album, - }); + return route.fallback(); + }); + + await context.route('**/api/albums**', async (route, request) => { + const allAlbums = request.url().match(/\/api\/albums\?assetId=(?[^&]+)/); + if (allAlbums) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: [], + }); + } + return route.fallback(); }); }; diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts new file mode 100644 index 0000000000..eaf9d0d073 --- /dev/null +++ b/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts @@ -0,0 +1,156 @@ +import { faker } from '@faker-js/faker'; +import { test } from '@playwright/test'; +import { + Changes, + createDefaultTimelineConfig, + generateTimelineData, + SeededRandom, + selectRandom, + TimelineAssetConfig, + TimelineData, +} from 'src/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; +import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; +import { utils } from 'src/utils'; +import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils'; + +test.describe.configure({ mode: 'parallel' }); +test.describe('asset-viewer', () => { + const rng = new SeededRandom(529); + let adminUserId: string; + let timelineRestData: TimelineData; + const assets: TimelineAssetConfig[] = []; + const yearMonths: string[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + + test.beforeAll(async () => { + utils.initSdk(); + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId }); + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + for (const yearMonth of timelineRestData.buckets.keys()) { + const [year, month] = yearMonth.split('-'); + yearMonths.push(`${year}-${Number(month)}`); + } + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + }); + + test.afterEach(() => { + cancelAllPollers(); + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + }); + + test.describe('/photos/:id', () => { + test('Delete photo advances to next', async ({ page }) => { + const asset = selectRandom(assets, rng); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + }); + test('Delete photo advances to next (2x)', async ({ page }) => { + const asset = selectRandom(assets, rng); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + await page.getByLabel('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]); + }); + test('Delete last photo advances to prev', async ({ page }) => { + const asset = assets.at(-1)!; + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]); + }); + test('Delete last photo advances to prev (2x)', async ({ page }) => { + const asset = assets.at(-1)!; + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + const index = assets.indexOf(asset); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]); + await page.getByLabel('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]); + }); + }); + test.describe('/trash/photos/:id', () => { + test('Delete trashed photo advances to next', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + }); + test('Delete trashed photo advances to next 2x', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]); + }); + test('Delete trashed photo advances to prev', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${assets[index + 9].id}`); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]); + }); + test('Delete trashed photo advances to prev 2x', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id); + changes.assetDeletions.push(...deletedAssets); + await page.goto(`/trash/photos/${assets[index + 9].id}`); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]); + await page.getByLabel('Delete').click(); + // confirm dialog + await page.getByRole('button').getByText('Delete').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]); + }); + }); +}); diff --git a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts index 6314688abb..5faf8380d1 100644 --- a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts +++ b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts @@ -463,7 +463,7 @@ test.describe('Timeline', () => { }); changes.albumAdditions.push(...requestJson.ids); }); - await page.getByText('Done').click(); + await page.getByText('Add assets').click(); await expect(put).resolves.toEqual({ ids: [ 'c077ea7b-cfa1-45e4-8554-f86c00ee5658', diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 9c80f25ace..48433eb830 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'vitest/config'; // skip `docker compose up` if `make e2e` was already run -const globalSetup: string[] = ['src/setup/auth-server.ts']; +const globalSetup: string[] = []; try { await fetch('http://127.0.0.1:2285/api/server-info/ping'); } catch { diff --git a/i18n/en.json b/i18n/en.json index f163d2c800..31df29a6c9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -18,6 +18,7 @@ "add_a_title": "Add a title", "add_action": "Add action", "add_action_description": "Click to add an action to perform", + "add_assets": "Add assets", "add_birthday": "Add a birthday", "add_endpoint": "Add endpoint", "add_exclusion_pattern": "Add exclusion pattern", @@ -478,6 +479,7 @@ "album_summary": "Album summary", "album_updated": "Album updated", "album_updated_setting_description": "Receive an email notification when a shared album has new assets", + "album_upload_assets": "Upload assets from your computer and add to album", "album_user_left": "Left {album}", "album_user_removed": "Removed {user}", "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", @@ -734,6 +736,18 @@ "checksum": "Checksum", "choose_matching_people_to_merge": "Choose matching people to merge", "city": "City", + "cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?", + "cleanup_confirm_prompt_title": "Remove from this device?", + "cleanup_deleted_assets": "Moved {count} assets to device trash", + "cleanup_deleting": "Moving to trash...", + "cleanup_filter_description": "Choose which types of assets to remove in the cleanup", + "cleanup_found_assets": "Found {count} backed up assets", + "cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan", + "cleanup_no_assets_found": "No backed up assets found matching your criteria", + "cleanup_preview_title": "Assets to remove ({count})", + "cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options", + "cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device", + "cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash", "clear": "Clear", "clear_all": "Clear all", "clear_all_recent_searches": "Clear all recent searches", @@ -823,9 +837,13 @@ "current_device": "Current device", "current_pin_code": "Current PIN code", "current_server_address": "Current server address", + "custom_date": "Custom date", "custom_locale": "Custom Locale", "custom_locale_description": "Format dates and numbers based on the language and the region", "custom_url": "Custom URL", + "cutoff_date_description": "Remove photos and videos older than", + "cutoff_day": "{count, plural, one {day} other {days}}", + "cutoff_year": "{count, plural, one {year} other {years}}", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "dark": "Dark", @@ -1148,6 +1166,7 @@ "filetype": "Filetype", "filter": "Filter", "filter_description": "Conditions to filter the target assets", + "filter_options": "Filter options", "filter_people": "Filter people", "filter_places": "Filter places", "filters": "Filters", @@ -1160,6 +1179,9 @@ "folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "forgot_pin_code_question": "Forgot your PIN?", "forward": "Forward", + "free_up_space": "Free Up Space", + "free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe", + "free_up_space_settings_subtitle": "Free up device storage", "full_path": "Full path: {path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", @@ -1276,6 +1298,8 @@ "json_error": "JSON error", "keep": "Keep", "keep_all": "Keep All", + "keep_favorites": "Keep favorites", + "keep_favorites_description": "Favorite assets will not be deleted from your device", "keep_this_delete_others": "Keep this, delete others", "kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Keyboard shortcuts", @@ -1446,6 +1470,7 @@ "move_down": "Move down", "move_off_locked_folder": "Move out of locked folder", "move_to": "Move to", + "move_to_device_trash": "Move to device trash", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", @@ -1628,6 +1653,7 @@ "photos_and_videos": "Photos & Videos", "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", + "photos_only": "Photos only", "pick_a_location": "Pick a location", "pick_custom_range": "Custom range", "pick_date_range": "Select a date range", @@ -1808,9 +1834,11 @@ "saved_settings": "Saved settings", "say_something": "Say something", "scaffold_body_error_occurred": "Error occurred", + "scan": "Scan", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", "scan_settings": "Scan Settings", + "scanning": "Scanning", "scanning_for_album": "Scanning for album...", "search": "Search", "search_albums": "Search albums", @@ -1882,6 +1910,7 @@ "select_all_in": "Select all in {group}", "select_avatar_color": "Select avatar color", "select_count": "{count, plural, one {Select #} other {Select #}}", + "select_cutoff_date": "Select cutoff date", "select_face": "Select face", "select_featured_photo": "Select featured photo", "select_from_computer": "Select from computer", @@ -2251,6 +2280,7 @@ "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", "videos": "Videos", "videos_count": "{count, plural, one {# Video} other {# Videos}}", + "videos_only": "Videos only", "view": "View", "view_album": "View Album", "view_all": "View All", diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index f1a46a2fef..63e84080df 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.* !default.perspectivev3 fastlane/report.xml -Gemfile.lock \ No newline at end of file +Gemfile.lock +certs/ \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index d167d5fb2d..9c31ced00d 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -44,7 +44,7 @@ def get_version_from_pubspec end # Helper method to configure code signing for all targets - def configure_code_signing(bundle_id_suffix: "") + def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:) bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" # Runner (main app) @@ -54,7 +54,7 @@ end team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, code_sign_identity: CODE_SIGN_IDENTITY, bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}", - profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore", + profile_name: profile_name_main, targets: ["Runner"] ) @@ -65,7 +65,7 @@ end team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, code_sign_identity: CODE_SIGN_IDENTITY, bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension", - profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore", + profile_name: profile_name_share, targets: ["ShareExtension"] ) @@ -76,7 +76,7 @@ end team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, code_sign_identity: CODE_SIGN_IDENTITY, bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget", - profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore", + profile_name: profile_name_widget, targets: ["WidgetExtension"] ) end @@ -87,7 +87,10 @@ end bundle_id_suffix: "", configuration: "Release", distribute_external: true, - version_number: nil + version_number: nil, + profile_name_main:, + profile_name_share:, + profile_name_widget: ) bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}" @@ -115,9 +118,9 @@ end xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", export_options: { provisioningProfiles: { - "#{app_identifier}" => "#{app_identifier} AppStore", - "#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore", - "#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore" + "#{app_identifier}" => profile_name_main, + "#{app_identifier}.ShareExtension" => profile_name_share, + "#{app_identifier}.Widget" => profile_name_widget }, signingStyle: "manual", signingCertificate: CODE_SIGN_IDENTITY @@ -136,20 +139,35 @@ end lane :gha_testflight_dev do api_key = get_api_key - # Install development provisioning profiles - install_provisioning_profile(path: "profile_dev.mobileprovision") - install_provisioning_profile(path: "profile_dev_share.mobileprovision") - install_provisioning_profile(path: "profile_dev_widget.mobileprovision") + # Download and install provisioning profiles from App Store Connect + # Certificate is imported by GHA workflow into build.keychain + # Capture profile names after each sigh call + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true) + main_profile_name = lane_context[SharedValues::SIGH_NAME] - # Configure code signing for dev bundle IDs - configure_code_signing(bundle_id_suffix: "development") + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true) + share_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true) + widget_profile_name = lane_context[SharedValues::SIGH_NAME] + + # Configure code signing for dev bundle IDs using the downloaded profile names + configure_code_signing( + bundle_id_suffix: "development", + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name + ) # Build and upload build_and_upload( api_key: api_key, bundle_id_suffix: "development", configuration: "Profile", - distribute_external: false + distribute_external: false, + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name ) end @@ -157,20 +175,33 @@ end lane :gha_release_prod do api_key = get_api_key - # Install provisioning profiles - install_provisioning_profile(path: "profile.mobileprovision") - install_provisioning_profile(path: "profile_share.mobileprovision") - install_provisioning_profile(path: "profile_widget.mobileprovision") + # Download and install provisioning profiles from App Store Connect + # Certificate is imported by GHA workflow into build.keychain + sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true) + main_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true) + share_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true) + widget_profile_name = lane_context[SharedValues::SIGH_NAME] # Configure code signing for production bundle IDs - configure_code_signing + configure_code_signing( + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name + ) # Build and upload with version number build_and_upload( api_key: api_key, version_number: get_version_from_pubspec, distribute_external: false, + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name ) end @@ -215,13 +246,26 @@ end # Use the same build process as production, just skip the upload # This ensures PR builds validate the same way as production builds - # Install provisioning profiles (use development profiles for PR builds) - install_provisioning_profile(path: "profile_dev.mobileprovision") - install_provisioning_profile(path: "profile_dev_share.mobileprovision") - install_provisioning_profile(path: "profile_dev_widget.mobileprovision") + api_key = get_api_key + + # Download and install provisioning profiles from App Store Connect + # Certificate is imported by GHA workflow into build.keychain + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true) + main_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true) + share_profile_name = lane_context[SharedValues::SIGH_NAME] + + sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true) + widget_profile_name = lane_context[SharedValues::SIGH_NAME] # Configure code signing for dev bundle IDs - configure_code_signing(bundle_id_suffix: "development") + configure_code_signing( + bundle_id_suffix: "development", + profile_name_main: main_profile_name, + profile_name_share: share_profile_name, + profile_name_widget: widget_profile_name + ) # Build the app (same as gha_testflight_dev but without upload) build_app( @@ -233,9 +277,9 @@ end xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", export_options: { provisioningProfiles: { - "#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore", - "#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore", - "#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore" + "#{BASE_BUNDLE_ID}.development" => main_profile_name, + "#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name, + "#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name }, signingStyle: "manual", signingCertificate: CODE_SIGN_IDENTITY diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 91ca50a2c0..c4505137d2 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -7,3 +7,7 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked } enum SortUserBy { id } enum ActionSource { timeline, viewer } + +enum CleanupStep { selectDate, filterOptions, scan, delete } + +enum AssetFilterType { all, photosOnly, videosOnly } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 3c214efbe0..558e94be9e 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -404,6 +404,7 @@ extension on Iterable { name: e.name, updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(), assetCount: e.assetCount, + isIosSharedAlbum: e.isCloud, ), ).toList(); } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 96630f1eba..e866a965c4 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -79,6 +79,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); + TimelineService map(String userId, LatLngBounds bounds) => TimelineService(_timelineRepository.map(userId, bounds, groupBy)); } diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index 707d3326a4..641a5359f6 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -33,6 +33,7 @@ extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { assetCount: assetCount, backupSelection: backupSelection, linkedRemoteAlbumId: linkedRemoteAlbumId, + isIosSharedAlbum: isIosSharedAlbum, ); } } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 4397dc1ea8..767ec4d39c 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/constants.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.dart'; @@ -129,39 +130,48 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return result; } - Future> getEmptyCloudIdAssets() { - final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull()); - return query.map((row) => row.toDto()).get(); - } + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) async { + final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]) + ..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true)); - Future> getHashMappingFromCloudId() async { - final query = - _db.localAssetEntity.selectOnly().join([ - leftOuterJoin( - _db.remoteAssetCloudIdEntity, - _db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId), - useColumns: false, - ), - leftOuterJoin( - _db.remoteAssetEntity, - _db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id), - useColumns: false, - ), - ]) - ..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum]) - ..where( - _db.remoteAssetCloudIdEntity.cloudId.isNotNull() & - _db.localAssetEntity.checksum.isNull() & - ((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) & - (_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) & - (_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) & - (_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))), - ); - final mapping = await query - .map( - (row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!), - ) - .get(); - return {for (final entry in mapping) entry.assetId: entry.checksum}; + final query = _db.localAssetEntity.select().join([ + innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)), + ]); + + Expression whereClause = + _db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate) & + _db.remoteAssetEntity.ownerId.equals(userId) & + _db.remoteAssetEntity.deletedAt.isNull(); + + // Exclude assets that are in iOS shared albums + whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets); + + if (filterType == AssetFilterType.photosOnly) { + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image); + } else if (filterType == AssetFilterType.videosOnly) { + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video); + } + + if (keepFavorites) { + whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false); + } + + query.where(whereClause); + + final rows = await query.get(); + return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); } } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index d35363c6ba..e625b57c1a 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -257,6 +257,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { + // Sort assets by date descending and group by day + final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final Map bucketCounts = {}; + for (final asset in sorted) { + final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day); + bucketCounts[date] = (bucketCounts[date] ?? 0) + 1; + } + + final buckets = bucketCounts.entries.map((e) => TimeBucket(date: e.key, assetCount: e.value)).toList(); + + return ( + bucketSource: () => Stream.value(buckets), + assetSource: (offset, count) => Future.value(sorted.skip(offset).take(count).toList(growable: false)), + origin: origin, + ); + } + TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder( filter: (row) => row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 86c80253dc..a1d7e55f32 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; +import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart'; @@ -22,6 +23,7 @@ enum SettingSection { advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), + freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"), languages('language', Icons.language, "setting_languages_subtitle"), networking('networking_settings', Icons.wifi, "networking_subtitle"), notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"), @@ -38,6 +40,7 @@ enum SettingSection { SettingSection.assetViewer => const AssetViewerSettings(), SettingSection.backup => Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(), + SettingSection.freeUpSpace => const FreeUpSpaceSettings(), SettingSection.languages => const LanguageSettings(), SettingSection.networking => const NetworkingSettings(), SettingSection.notifications => const NotificationSetting(), diff --git a/mobile/lib/presentation/pages/cleanup_preview.page.dart b/mobile/lib/presentation/pages/cleanup_preview.page.dart new file mode 100644 index 0000000000..556ed6412f --- /dev/null +++ b/mobile/lib/presentation/pages/cleanup_preview.page.dart @@ -0,0 +1,42 @@ +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/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +@RoutePage() +class CleanupPreviewPage extends StatelessWidget { + final List assets; + + const CleanupPreviewPage({super.key, required this.assets}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})), + centerTitle: true, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: context.colorScheme.surface, + ), + body: ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith((ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .fromAssetsWithBuckets(assets.cast(), TimelineOrigin.search); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index a04e26d653..ac20e73190 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -42,6 +42,7 @@ class Timeline extends StatelessWidget { this.withScrubber = true, this.snapToMonth = true, this.initialScrollOffset, + this.readOnly = false, }); final Widget? topSliverWidget; @@ -54,6 +55,7 @@ class Timeline extends StatelessWidget { final bool withScrubber; final bool snapToMonth; final double? initialScrollOffset; + final bool readOnly; @override Widget build(BuildContext context) { @@ -73,6 +75,7 @@ class Timeline extends StatelessWidget { groupBy: groupBy, ), ), + if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), ], child: _SliverTimeline( topSliverWidget: topSliverWidget, @@ -89,6 +92,17 @@ class Timeline extends StatelessWidget { } } +class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier { + @override + bool build() => true; + + @override + void setReadonlyMode(bool value) {} + + @override + void toggleReadonlyMode() {} +} + class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart new file mode 100644 index 0000000000..5b3b152f34 --- /dev/null +++ b/mobile/lib/providers/cleanup.provider.dart @@ -0,0 +1,106 @@ +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/providers/user.provider.dart'; +import 'package:immich_mobile/services/cleanup.service.dart'; + +class CleanupState { + final DateTime? selectedDate; + final List assetsToDelete; + final bool isScanning; + final bool isDeleting; + final AssetFilterType filterType; + final bool keepFavorites; + + const CleanupState({ + this.selectedDate, + this.assetsToDelete = const [], + this.isScanning = false, + this.isDeleting = false, + this.filterType = AssetFilterType.all, + this.keepFavorites = true, + }); + + CleanupState copyWith({ + DateTime? selectedDate, + List? assetsToDelete, + bool? isScanning, + bool? isDeleting, + AssetFilterType? filterType, + bool? keepFavorites, + }) { + return CleanupState( + selectedDate: selectedDate ?? this.selectedDate, + assetsToDelete: assetsToDelete ?? this.assetsToDelete, + isScanning: isScanning ?? this.isScanning, + isDeleting: isDeleting ?? this.isDeleting, + filterType: filterType ?? this.filterType, + keepFavorites: keepFavorites ?? this.keepFavorites, + ); + } +} + +final cleanupProvider = StateNotifierProvider((ref) { + return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id); +}); + +class CleanupNotifier extends StateNotifier { + final CleanupService _cleanupService; + final String? _userId; + + CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState()); + + void setSelectedDate(DateTime? date) { + state = state.copyWith(selectedDate: date, assetsToDelete: []); + } + + void setFilterType(AssetFilterType filterType) { + state = state.copyWith(filterType: filterType, assetsToDelete: []); + } + + void setKeepFavorites(bool keepFavorites) { + state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); + } + + Future scanAssets() async { + if (_userId == null || state.selectedDate == null) { + return; + } + + state = state.copyWith(isScanning: true); + try { + final assets = await _cleanupService.getRemovalCandidates( + _userId, + state.selectedDate!, + filterType: state.filterType, + keepFavorites: state.keepFavorites, + ); + state = state.copyWith(assetsToDelete: assets, isScanning: false); + } catch (e) { + state = state.copyWith(isScanning: false); + rethrow; + } + } + + Future deleteAssets() async { + if (state.assetsToDelete.isEmpty) { + return 0; + } + + state = state.copyWith(isDeleting: true); + try { + final deletedCount = await _cleanupService.deleteLocalAssets(state.assetsToDelete.map((a) => a.id).toList()); + + state = state.copyWith(assetsToDelete: [], isDeleting: false); + + return deletedCount; + } catch (e) { + state = state.copyWith(isDeleting: false); + rethrow; + } + } + + void reset() { + state = const CleanupState(); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9c4a193381..9468b105e5 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -88,6 +88,7 @@ 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'; @@ -338,6 +339,7 @@ class AppRouter extends RootStackRouter { 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 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 939bf73369..b287d73114 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -611,6 +611,43 @@ class ChangePasswordRoute extends PageRouteInfo { ); } +/// generated route for +/// [CleanupPreviewPage] +class CleanupPreviewRoute extends PageRouteInfo { + CleanupPreviewRoute({ + Key? key, + required List assets, + List? children, + }) : super( + CleanupPreviewRoute.name, + args: CleanupPreviewRouteArgs(key: key, assets: assets), + initialChildren: children, + ); + + static const String name = 'CleanupPreviewRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return CleanupPreviewPage(key: args.key, assets: args.assets); + }, + ); +} + +class CleanupPreviewRouteArgs { + const CleanupPreviewRouteArgs({this.key, required this.assets}); + + final Key? key; + + final List assets; + + @override + String toString() { + return 'CleanupPreviewRouteArgs{key: $key, assets: $assets}'; + } +} + /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart new file mode 100644 index 0000000000..6a4318d209 --- /dev/null +++ b/mobile/lib/services/cleanup.service.dart @@ -0,0 +1,45 @@ +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/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; + +final cleanupServiceProvider = Provider((ref) { + return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider)); +}); + +class CleanupService { + final DriftLocalAssetRepository _localAssetRepository; + final AssetMediaRepository _assetMediaRepository; + + const CleanupService(this._localAssetRepository, this._assetMediaRepository); + + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) { + return _localAssetRepository.getRemovalCandidates( + userId, + cutoffDate, + filterType: filterType, + keepFavorites: keepFavorites, + ); + } + + Future deleteLocalAssets(List localIds) async { + if (localIds.isEmpty) { + return 0; + } + + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + return deletedIds.length; + } + + return 0; + } +} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 35cdc7addf..30a9702b53 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -31,7 +31,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 19; +const int targetVersion = 20; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -86,6 +86,10 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { } } + if (version < 20 && Store.isBetaTimelineEnabled) { + await _syncLocalAlbumIsIosSharedAlbum(drift); + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -258,6 +262,25 @@ Future _populateLocalAssetTime(Drift db) async { } } +Future _syncLocalAlbumIsIosSharedAlbum(Drift db) async { + try { + final nativeApi = NativeSyncApi(); + final albums = await nativeApi.getAlbums(); + await db.batch((batch) { + for (final album in albums) { + batch.update( + db.localAlbumEntity, + LocalAlbumEntityCompanion(isIosSharedAlbum: Value(album.isCloud)), + where: (t) => t.id.equals(album.id), + ); + } + }); + dPrint(() => "[MIGRATION] Successfully updated isIosSharedAlbum for ${albums.length} albums"); + } catch (error) { + dPrint(() => "[MIGRATION] Error while syncing local album isIosSharedAlbum: $error"); + } +} + Future migrateDeviceAssetToSqlite(Isar db, Drift drift) async { try { final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll(); diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart index 596e46d934..84128ddde2 100644 --- a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart @@ -4,6 +4,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -41,6 +42,13 @@ class DriftAlbumInfoListTile extends HookConsumerWidget { return Icon(Icons.circle, color: context.colorScheme.surfaceContainerHighest); } + Widget buildSubtitle() { + return Text( + album.isIosSharedAlbum ? '${album.assetCount} (iCloud Shared Album)' : album.assetCount.toString(), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ); + } + return GestureDetector( onDoubleTap: () { ref.watch(hapticFeedbackProvider.notifier).selectionClick(); @@ -73,8 +81,8 @@ class DriftAlbumInfoListTile extends HookConsumerWidget { } }, leading: buildIcon(), - title: Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - subtitle: Text(album.assetCount.toString()), + title: Text(album.name, style: context.textTheme.titleSmall), + subtitle: buildSubtitle(), trailing: IconButton( onPressed: () { context.pushRoute(LocalTimelineRoute(album: album)); diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart new file mode 100644 index 0000000000..7acb04686b --- /dev/null +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -0,0 +1,702 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/cleanup.provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class FreeUpSpaceSettings extends ConsumerStatefulWidget { + const FreeUpSpaceSettings({super.key}); + + @override + ConsumerState createState() => _FreeUpSpaceSettingsState(); +} + +class _FreeUpSpaceSettingsState extends ConsumerState { + CleanupStep _currentStep = CleanupStep.selectDate; + bool _hasScanned = false; + + void _resetState() { + ref.read(cleanupProvider.notifier).reset(); + _hasScanned = false; + } + + CleanupStep get _calculatedStep { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isNotEmpty) { + return CleanupStep.delete; + } + + if (state.selectedDate != null) { + return CleanupStep.filterOptions; + } + + return CleanupStep.selectDate; + } + + void _goToFiltersStep() { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + setState(() => _currentStep = CleanupStep.filterOptions); + } + + void _goToScanStep() { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + setState(() => _currentStep = CleanupStep.scan); + } + + void _setPresetDate(int daysAgo) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final date = DateTime.now().subtract(Duration(days: daysAgo)); + ref.read(cleanupProvider.notifier).setSelectedDate(date); + setState(() => _hasScanned = false); + } + + bool _isPresetSelected(int? daysAgo) { + final state = ref.read(cleanupProvider); + if (state.selectedDate == null) return false; + + final expectedDate = daysAgo != null ? DateTime.now().subtract(Duration(days: daysAgo)) : DateTime(2000); + + // Check if dates match (ignoring time component) + return state.selectedDate!.year == expectedDate.year && + state.selectedDate!.month == expectedDate.month && + state.selectedDate!.day == expectedDate.day; + } + + Future _selectDate() async { + final state = ref.read(cleanupProvider); + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: state.selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + + if (picked != null) { + ref.read(cleanupProvider.notifier).setSelectedDate(picked); + } + } + + Future _scanAssets() async { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + + await ref.read(cleanupProvider.notifier).scanAssets(); + final state = ref.read(cleanupProvider); + + setState(() { + _hasScanned = true; + if (state.assetsToDelete.isNotEmpty) { + _currentStep = CleanupStep.delete; + } + }); + } + + Future _deleteAssets() async { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isEmpty || state.selectedDate == null) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => + _DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!), + ); + + if (confirmed != true) { + return; + } + + final deletedCount = await ref.read(cleanupProvider.notifier).deleteAssets(); + + if (mounted && deletedCount > 0) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + + await showDialog( + context: context, + builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount), + ); + } + + setState(() => _currentStep = CleanupStep.selectDate); + } + + void _showAssetsPreview(List assets) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + context.pushRoute(CleanupPreviewRoute(assets: assets)); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(cleanupProvider); + final hasDate = state.selectedDate != null; + final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty; + + StepStyle styleForState(StepState stepState, {bool isDestructive = false}) { + switch (stepState) { + case StepState.complete: + return StepStyle( + color: context.colorScheme.primary, + indexStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.w500), + ); + case StepState.disabled: + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.38), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + case StepState.indexed: + case StepState.editing: + case StepState.error: + if (isDestructive) { + return StepStyle( + color: context.colorScheme.error, + indexStyle: TextStyle(color: context.colorScheme.onError, fontWeight: FontWeight.w500), + ); + } + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + } + } + + final step1State = hasDate ? StepState.complete : StepState.indexed; + final step2State = hasDate ? StepState.complete : StepState.disabled; + final step3State = hasAssets + ? StepState.complete + : hasDate + ? StepState.indexed + : StepState.disabled; + final step4State = hasAssets ? StepState.indexed : StepState.disabled; + + String getFilterSubtitle() { + final parts = []; + switch (state.filterType) { + case AssetFilterType.all: + parts.add('all'.t(context: context)); + case AssetFilterType.photosOnly: + parts.add('photos_only'.t(context: context)); + case AssetFilterType.videosOnly: + parts.add('videos_only'.t(context: context)); + } + if (state.keepFavorites) { + parts.add('keep_favorites'.t(context: context)); + } + return parts.join(' • '); + } + + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + _resetState(); + } + }, + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)), + ), + child: Text( + 'free_up_space_description'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), + ), + ), + + Stepper( + physics: const NeverScrollableScrollPhysics(), + currentStep: _currentStep.index, + onStepTapped: (step) { + // Only allow going back or to completed steps + if (step <= _calculatedStep.index) { + setState(() => _currentStep = CleanupStep.values[step]); + } + }, + controlsBuilder: (_, __) => const SizedBox.shrink(), + steps: [ + // Step 1: Select Cutoff Date + Step( + stepStyle: styleForState(step1State), + title: Text( + 'select_cutoff_date'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step1State == StepState.complete + ? context.colorScheme.primary + : context.colorScheme.onSurface, + ), + ), + subtitle: hasDate + ? Text( + DateFormat.yMMMd().format(state.selectedDate!), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cutoff_date_description'.t(context: context), style: context.textTheme.labelLarge), + const SizedBox(height: 16), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.4, + children: [ + _DatePresetCard( + value: '30', + unit: 'cutoff_day'.t(context: context, args: {'count': '30'}), + onTap: () => _setPresetDate(30), + isSelected: _isPresetSelected(30), + ), + _DatePresetCard( + value: '60', + unit: 'cutoff_day'.t(context: context, args: {'count': '60'}), + + onTap: () => _setPresetDate(60), + isSelected: _isPresetSelected(60), + ), + _DatePresetCard( + value: '90', + unit: 'cutoff_day'.t(context: context, args: {'count': '90'}), + + onTap: () => _setPresetDate(90), + isSelected: _isPresetSelected(90), + ), + _DatePresetCard( + value: '1', + unit: 'cutoff_year'.t(context: context, args: {'count': '1'}), + onTap: () => _setPresetDate(365), + isSelected: _isPresetSelected(365), + ), + _DatePresetCard( + value: '2', + unit: 'cutoff_year'.t(context: context, args: {'count': '2'}), + onTap: () => _setPresetDate(730), + isSelected: _isPresetSelected(730), + ), + _DatePresetCard( + value: '3', + unit: 'cutoff_year'.t(context: context, args: {'count': '3'}), + onTap: () => _setPresetDate(1095), + isSelected: _isPresetSelected(1095), + ), + ], + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _selectDate, + icon: const Icon(Icons.calendar_today), + label: Text('custom_date'.t(context: context)), + style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: hasDate ? () => _goToFiltersStep() : null, + icon: const Icon(Icons.arrow_forward), + label: Text('continue'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + ], + ), + isActive: true, + state: step1State, + ), + + // Step 2: Select Filter Options + Step( + stepStyle: styleForState(step2State), + title: Text( + 'filter_options'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step2State == StepState.complete + ? context.colorScheme.primary + : step2State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.onSurface, + ), + ), + subtitle: hasDate + ? Text( + getFilterSubtitle(), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cleanup_filter_description'.t(context: context), style: context.textTheme.labelLarge), + const SizedBox(height: 16), + SegmentedButton( + segments: [ + ButtonSegment( + value: AssetFilterType.all, + label: Text('all'.t(context: context)), + icon: const Icon(Icons.photo_library), + ), + ButtonSegment( + value: AssetFilterType.photosOnly, + label: Text('photos'.t(context: context)), + icon: const Icon(Icons.photo), + ), + ButtonSegment( + value: AssetFilterType.videosOnly, + label: Text('videos'.t(context: context)), + icon: const Icon(Icons.videocam), + ), + ], + selected: {state.filterType}, + onSelectionChanged: (selection) { + ref.read(cleanupProvider.notifier).setFilterType(selection.first); + setState(() => _hasScanned = false); + }, + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text('keep_favorites'.t(context: context), style: context.textTheme.titleSmall), + subtitle: Text( + 'keep_favorites_description'.t(context: context), + style: context.textTheme.labelLarge, + ), + value: state.keepFavorites, + onChanged: (value) { + ref.read(cleanupProvider.notifier).setKeepFavorites(value); + setState(() => _hasScanned = false); + }, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _goToScanStep, + icon: const Icon(Icons.arrow_forward), + label: Text('continue'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + ], + ), + isActive: hasDate, + state: step2State, + ), + + // Step 3: Scan Assets + Step( + stepStyle: styleForState(step3State), + title: Text( + 'scan'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step3State == StepState.complete + ? context.colorScheme.primary + : step3State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.onSurface, + ), + ), + subtitle: _hasScanned + ? Text( + 'cleanup_found_assets'.t( + context: context, + args: {'count': state.assetsToDelete.length.toString()}, + ), + style: context.textTheme.bodyMedium?.copyWith( + color: state.assetsToDelete.isNotEmpty + ? context.colorScheme.primary + : context.colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + children: [ + Text( + 'cleanup_step3_description'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), + if (CurrentPlatform.isIOS) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: context.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'cleanup_icloud_shared_albums_excluded'.t(context: context), + style: context.textTheme.labelLarge, + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + state.isScanning + ? SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: context.colorScheme.primary.withAlpha(50), + ), + ) + : ElevatedButton.icon( + onPressed: state.isScanning ? null : _scanAssets, + icon: const Icon(Icons.search), + label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + if (_hasScanned && state.assetsToDelete.isEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Row( + children: [ + const Icon(Icons.info, color: Colors.orange), + const SizedBox(width: 12), + Expanded( + child: Text( + 'cleanup_no_assets_found'.t(context: context), + style: context.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ], + ), + isActive: hasDate, + state: step3State, + ), + + // Step 4: Delete Assets + Step( + stepStyle: styleForState(step4State, isDestructive: true), + title: Text( + 'move_to_device_trash'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step4State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.error, + ), + ), + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)), + ), + child: hasAssets + ? Text( + 'cleanup_step4_summary'.t( + context: context, + args: { + 'count': state.assetsToDelete.length.toString(), + 'date': DateFormat.yMMMd().format(state.selectedDate!), + }, + ), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ) + : null, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => _showAssetsPreview(state.assetsToDelete), + icon: const Icon(Icons.preview), + label: Text('preview'.t(context: context)), + style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: state.isDeleting ? null : _deleteAssets, + icon: state.isDeleting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.delete_forever), + label: Text( + state.isDeleting + ? 'cleanup_deleting'.t(context: context) + : 'move_to_device_trash'.t(context: context), + ), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + minimumSize: const Size(double.infinity, 56), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + isActive: hasAssets, + state: step4State, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _DeleteConfirmationDialog extends StatelessWidget { + final int assetCount; + final DateTime cutoffDate; + + const _DeleteConfirmationDialog({required this.assetCount, required this.cutoffDate}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('cleanup_confirm_prompt_title'.t(context: context)), + content: Text( + 'cleanup_confirm_description'.t( + context: context, + args: {'count': assetCount.toString(), 'date': DateFormat.yMMMd().format(cutoffDate)}, + ), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: Text('cancel'.t(context: context)), + ), + ElevatedButton( + onPressed: () => context.pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + ), + child: Text('confirm'.t(context: context)), + ), + ], + ); + } +} + +class _DeleteSuccessDialog extends StatelessWidget { + final int deletedCount; + + const _DeleteSuccessDialog({required this.deletedCount}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: Icon(Icons.check_circle, color: context.colorScheme.primary, size: 48), + title: Text('success'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()}), + style: context.textTheme.labelLarge?.copyWith(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'cleanup_trash_hint'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 16, color: context.primaryColor), + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => context.pop(), + child: Text('done'.t(context: context)), + ), + ], + ); + } +} + +class _DatePresetCard extends StatelessWidget { + final String value; + final String unit; + final VoidCallback onTap; + final bool isSelected; + + const _DatePresetCard({required this.value, required this.unit, required this.onTap, required this.isSelected}); + + @override + Widget build(BuildContext context) { + return Material( + color: isSelected ? context.colorScheme.primaryContainer.withAlpha(100) : context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: isSelected ? context.colorScheme.primary : Colors.transparent, width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + value, + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurface, + ), + ), + Text( + unit, + style: context.textTheme.bodySmall?.copyWith( + color: isSelected + ? context.colorScheme.primary + : context.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7277a99ac8..657e62bf6d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -100,6 +100,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset *AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets +*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset @@ -114,6 +115,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset *AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | Update assets +*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password @@ -358,7 +360,11 @@ Class | Method | HTTP request | Description - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) - [AssetMediaSize](doc//AssetMediaSize.md) - [AssetMediaStatus](doc//AssetMediaStatus.md) - - [AssetMetadataKey](doc//AssetMetadataKey.md) + - [AssetMetadataBulkDeleteDto](doc//AssetMetadataBulkDeleteDto.md) + - [AssetMetadataBulkDeleteItemDto](doc//AssetMetadataBulkDeleteItemDto.md) + - [AssetMetadataBulkResponseDto](doc//AssetMetadataBulkResponseDto.md) + - [AssetMetadataBulkUpsertDto](doc//AssetMetadataBulkUpsertDto.md) + - [AssetMetadataBulkUpsertItemDto](doc//AssetMetadataBulkUpsertItemDto.md) - [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md) - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 05d1803979..59f31d1392 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -109,7 +109,11 @@ part 'model/asset_jobs_dto.dart'; part 'model/asset_media_response_dto.dart'; part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; -part 'model/asset_metadata_key.dart'; +part 'model/asset_metadata_bulk_delete_dto.dart'; +part 'model/asset_metadata_bulk_delete_item_dto.dart'; +part 'model/asset_metadata_bulk_response_dto.dart'; +part 'model/asset_metadata_bulk_upsert_dto.dart'; +part 'model/asset_metadata_bulk_upsert_item_dto.dart'; part 'model/asset_metadata_response_dto.dart'; part 'model/asset_metadata_upsert_dto.dart'; part 'model/asset_metadata_upsert_item_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 5020afc4b2..ac50d015ed 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -186,12 +186,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadataWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -222,8 +222,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadata(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadata(String id, String key,) async { final response = await deleteAssetMetadataWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -278,6 +278,54 @@ class AssetsApi { } } + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadataWithHttpInfo(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadata(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + final response = await deleteBulkAssetMetadataWithHttpInfo(assetMetadataBulkDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Download original asset /// /// Downloads the original file of the specified asset. @@ -552,12 +600,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKeyWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -588,8 +636,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKey(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKey(String id, String key,) async { final response = await getAssetMetadataByKeyWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -1228,6 +1276,65 @@ class AssetsApi { } } + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future updateBulkAssetMetadataWithHttpInfo(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future?> updateBulkAssetMetadata(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + final response = await updateBulkAssetMetadataWithHttpInfo(assetMetadataBulkUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Upload asset /// /// Uploads a new asset to the server. @@ -1246,8 +1353,6 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [List] metadata (required): - /// /// * [String] key: /// /// * [String] slug: @@ -1263,10 +1368,12 @@ class AssetsApi { /// /// * [String] livePhotoVideoId: /// + /// * [List] metadata: + /// /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets'; @@ -1373,8 +1480,6 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [List] metadata (required): - /// /// * [String] key: /// /// * [String] slug: @@ -1390,11 +1495,13 @@ class AssetsApi { /// /// * [String] livePhotoVideoId: /// + /// * [List] metadata: + /// /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, metadata, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, ); 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 39aea82c89..6f68e44cef 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -266,8 +266,16 @@ class ApiClient { return AssetMediaSizeTypeTransformer().decode(value); case 'AssetMediaStatus': return AssetMediaStatusTypeTransformer().decode(value); - case 'AssetMetadataKey': - return AssetMetadataKeyTypeTransformer().decode(value); + case 'AssetMetadataBulkDeleteDto': + return AssetMetadataBulkDeleteDto.fromJson(value); + case 'AssetMetadataBulkDeleteItemDto': + return AssetMetadataBulkDeleteItemDto.fromJson(value); + case 'AssetMetadataBulkResponseDto': + return AssetMetadataBulkResponseDto.fromJson(value); + case 'AssetMetadataBulkUpsertDto': + return AssetMetadataBulkUpsertDto.fromJson(value); + case 'AssetMetadataBulkUpsertItemDto': + return AssetMetadataBulkUpsertItemDto.fromJson(value); case 'AssetMetadataResponseDto': return AssetMetadataResponseDto.fromJson(value); case 'AssetMetadataUpsertDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 39caa4534f..1a5f703c78 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -67,9 +67,6 @@ String parameterToString(dynamic value) { if (value is AssetMediaStatus) { return AssetMediaStatusTypeTransformer().encode(value).toString(); } - if (value is AssetMetadataKey) { - return AssetMetadataKeyTypeTransformer().encode(value).toString(); - } if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart new file mode 100644 index 0000000000..23c34d7152 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkDeleteDto { + /// Returns a new [AssetMetadataBulkDeleteDto] instance. + AssetMetadataBulkDeleteDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteDto( + items: AssetMetadataBulkDeleteItemDto.listFromJson(json[r'items']), + ); + } + 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 = AssetMetadataBulkDeleteDto.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 = AssetMetadataBulkDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteDto-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] = AssetMetadataBulkDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart new file mode 100644 index 0000000000..a3a111f9f7 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkDeleteItemDto { + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance. + AssetMetadataBulkDeleteItemDto({ + required this.assetId, + required this.key, + }); + + String assetId; + + String key; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteItemDto && + other.assetId == assetId && + other.key == key; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteItemDto[assetId=$assetId, key=$key]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + ); + } + 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 = AssetMetadataBulkDeleteItemDto.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 = AssetMetadataBulkDeleteItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteItemDto-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] = AssetMetadataBulkDeleteItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart new file mode 100644 index 0000000000..15c130930b --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart @@ -0,0 +1,123 @@ +// +// 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 AssetMetadataBulkResponseDto { + /// Returns a new [AssetMetadataBulkResponseDto] instance. + AssetMetadataBulkResponseDto({ + required this.assetId, + required this.key, + required this.updatedAt, + required this.value, + }); + + String assetId; + + String key; + + DateTime updatedAt; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkResponseDto && + other.assetId == assetId && + other.key == key && + other.updatedAt == updatedAt && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkResponseDto[assetId=$assetId, key=$key, updatedAt=$updatedAt, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkResponseDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, + ); + } + 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 = AssetMetadataBulkResponseDto.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 = AssetMetadataBulkResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkResponseDto-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] = AssetMetadataBulkResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'updatedAt', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart new file mode 100644 index 0000000000..fe9d9ed251 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkUpsertDto { + /// Returns a new [AssetMetadataBulkUpsertDto] instance. + AssetMetadataBulkUpsertDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertDto( + items: AssetMetadataBulkUpsertItemDto.listFromJson(json[r'items']), + ); + } + 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 = AssetMetadataBulkUpsertDto.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 = AssetMetadataBulkUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertDto-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] = AssetMetadataBulkUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart new file mode 100644 index 0000000000..25a219537e --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataBulkUpsertItemDto { + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance. + AssetMetadataBulkUpsertItemDto({ + required this.assetId, + required this.key, + required this.value, + }); + + String assetId; + + String key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertItemDto && + other.assetId == assetId && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertItemDto[assetId=$assetId, key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + value: mapValueOfType(json, r'value')!, + ); + } + 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 = AssetMetadataBulkUpsertItemDto.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 = AssetMetadataBulkUpsertItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertItemDto-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] = AssetMetadataBulkUpsertItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_key.dart b/mobile/openapi/lib/model/asset_metadata_key.dart deleted file mode 100644 index 70186cd41c..0000000000 --- a/mobile/openapi/lib/model/asset_metadata_key.dart +++ /dev/null @@ -1,82 +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 AssetMetadataKey { - /// Instantiate a new enum with the provided [value]. - const AssetMetadataKey._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const mobileApp = AssetMetadataKey._(r'mobile-app'); - - /// List of all possible values in this [enum][AssetMetadataKey]. - static const values = [ - mobileApp, - ]; - - static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AssetMetadataKey.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String, -/// and [decode] dynamic data back to [AssetMetadataKey]. -class AssetMetadataKeyTypeTransformer { - factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._(); - - const AssetMetadataKeyTypeTransformer._(); - - String encode(AssetMetadataKey data) => data.value; - - /// Decodes a [dynamic value][data] to a AssetMetadataKey. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'mobile-app': return AssetMetadataKey.mobileApp; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [AssetMetadataKeyTypeTransformer] instance. - static AssetMetadataKeyTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/asset_metadata_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_response_dto.dart index af5769b9bb..cccf42ae87 100644 --- a/mobile/openapi/lib/model/asset_metadata_response_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_response_dto.dart @@ -18,7 +18,7 @@ class AssetMetadataResponseDto { required this.value, }); - AssetMetadataKey key; + String key; DateTime updatedAt; @@ -57,7 +57,7 @@ class AssetMetadataResponseDto { final json = value.cast(); return AssetMetadataResponseDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, value: mapValueOfType(json, r'value')!, ); diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart index 4b7e6579a1..3d247f8572 100644 --- a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart @@ -17,7 +17,7 @@ class AssetMetadataUpsertItemDto { required this.value, }); - AssetMetadataKey key; + String key; Object value; @@ -51,7 +51,7 @@ class AssetMetadataUpsertItemDto { final json = value.cast(); return AssetMetadataUpsertItemDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart index c9a7ef4670..cf67b68dd2 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart @@ -19,7 +19,7 @@ class SyncAssetMetadataDeleteV1 { String assetId; - AssetMetadataKey key; + String key; @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 && @@ -52,7 +52,7 @@ class SyncAssetMetadataDeleteV1 { return SyncAssetMetadataDeleteV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, ); } return null; diff --git a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart index 720fcef947..4fa6ed84ed 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart @@ -20,7 +20,7 @@ class SyncAssetMetadataV1 { String assetId; - AssetMetadataKey key; + String key; Object value; @@ -58,7 +58,7 @@ class SyncAssetMetadataV1 { return SyncAssetMetadataV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart new file mode 100644 index 0000000000..0d686fbc09 --- /dev/null +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -0,0 +1,438 @@ +import 'package:drift/drift.dart'; +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/user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; + +void main() { + late Drift db; + late DriftLocalAssetRepository repository; + + setUp(() { + db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + repository = DriftLocalAssetRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('getRemovalCandidates', () { + final userId = 'user-123'; + final otherUserId = 'user-456'; + final now = DateTime(2024, 1, 15); + final cutoffDate = DateTime(2024, 1, 10); + final beforeCutoff = DateTime(2024, 1, 5); + final afterCutoff = DateTime(2024, 1, 12); + + Future insertUser(String id, String email) async { + await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email)); + } + + setUp(() async { + await insertUser(userId, 'user@test.com'); + await insertUser(otherUserId, 'other@test.com'); + }); + + Future insertLocalAsset({ + required String id, + required String checksum, + required DateTime createdAt, + required AssetType type, + required bool isFavorite, + }) async { + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: id, + name: 'asset_$id.jpg', + checksum: Value(checksum), + type: type, + createdAt: Value(createdAt), + updatedAt: Value(createdAt), + isFavorite: Value(isFavorite), + ), + ); + } + + 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 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 { + // 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); + + // 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, + ); + + // 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); + + // 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); + + // 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); + + // 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-1'); + }); + + test('includes favorites when keepFavorites is false', () async { + await insertLocalAsset( + id: 'local-favorite', + checksum: 'checksum-fav', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: true, + ); + await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-favorite'); + expect(candidates[0].isFavorite, true); + }); + + test('filters by photos only', () 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); + + // Video + 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 candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + filterType: AssetFilterType.photosOnly, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-photo'); + expect(candidates[0].type, AssetType.image); + }); + + test('filters by videos only', () 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); + + // Video + 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 candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + filterType: AssetFilterType.videosOnly, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-video'); + expect(candidates[0].type, AssetType.video); + }); + + test('returns both photos and videos with filterType.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); + + // Video + 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all); + + expect(candidates.length, 2); + final ids = candidates.map((a) => a.id).toSet(); + expect(ids, containsAll(['local-photo', 'local-video'])); + }); + + test('excludes assets in iOS shared albums', () async { + // Regular album + await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + + // iOS shared album + await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', 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'); + + // 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-regular'); + }); + + 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-exact'); + }); + + 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 2); + expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'})); + }); + + 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-no-album'); + }); + + 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); + + // iOS shared album + await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, 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 candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, isEmpty); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7e859ffee0..70f482c5b2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2528,6 +2528,16 @@ "required": true }, "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMediaResponseDto" + } + } + }, + "description": "Asset is a duplicate" + }, "201": { "content": { "application/json": { @@ -2536,7 +2546,7 @@ } } }, - "description": "" + "description": "Asset uploaded successfully" } }, "security": [ @@ -2906,6 +2916,112 @@ "x-immich-state": "Stable" } }, + "/assets/metadata": { + "delete": { + "description": "Delete metadata key-value pairs for multiple assets.", + "operationId": "deleteBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + }, + "put": { + "description": "Upsert metadata key-value pairs for multiple assets.", + "operationId": "updateBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Upsert asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + } + }, "/assets/random": { "get": { "deprecated": true, @@ -3340,7 +3456,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -3399,7 +3515,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -3637,7 +3753,7 @@ } } }, - "description": "" + "description": "Asset replaced successfully" } }, "security": [ @@ -15499,8 +15615,7 @@ "deviceAssetId", "deviceId", "fileCreatedAt", - "fileModifiedAt", - "metadata" + "fileModifiedAt" ], "type": "object" }, @@ -15575,20 +15690,98 @@ ], "type": "string" }, - "AssetMetadataKey": { - "enum": [ - "mobile-app" + "AssetMetadataBulkDeleteDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" ], - "type": "string" + "type": "object" + }, + "AssetMetadataBulkDeleteItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "assetId", + "key" + ], + "type": "object" + }, + "AssetMetadataBulkResponseDto": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "updatedAt", + "value" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "value" + ], + "type": "object" }, "AssetMetadataResponseDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "updatedAt": { "format": "date-time", @@ -15622,11 +15815,7 @@ "AssetMetadataUpsertItemDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" @@ -20651,11 +20840,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" } }, "required": [ @@ -20670,11 +20855,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b7c980f4f4..afc583f91d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -8,7 +8,7 @@ import * as Oazapfts from "@oazapfts/runtime"; import * as QS from "@oazapfts/runtime/query"; export const defaults: Oazapfts.Defaults = { headers: {}, - baseUrl: "/api", + baseUrl: "/api" }; const oazapfts = Oazapfts.runtime(defaults); export const servers = { @@ -471,7 +471,7 @@ export type AssetBulkDeleteDto = { ids: string[]; }; export type AssetMetadataUpsertItemDto = { - key: AssetMetadataKey; + key: string; value: object; }; export type AssetMediaCreateDto = { @@ -484,7 +484,7 @@ export type AssetMediaCreateDto = { filename?: string; isFavorite?: boolean; livePhotoVideoId?: string; - metadata: AssetMetadataUpsertItemDto[]; + metadata?: AssetMetadataUpsertItemDto[]; sidecarData?: Blob; visibility?: AssetVisibility; }; @@ -543,6 +543,27 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; +export type AssetMetadataBulkDeleteItemDto = { + assetId: string; + key: string; +}; +export type AssetMetadataBulkDeleteDto = { + items: AssetMetadataBulkDeleteItemDto[]; +}; +export type AssetMetadataBulkUpsertItemDto = { + assetId: string; + key: string; + value: object; +}; +export type AssetMetadataBulkUpsertDto = { + items: AssetMetadataBulkUpsertItemDto[]; +}; +export type AssetMetadataBulkResponseDto = { + assetId: string; + key: string; + updatedAt: string; + value: object; +}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -554,7 +575,7 @@ export type UpdateAssetDto = { visibility?: AssetVisibility; }; export type AssetMetadataResponseDto = { - key: AssetMetadataKey; + key: string; updatedAt: string; value: object; }; @@ -2369,6 +2390,9 @@ export function uploadAsset({ key, slug, xImmichChecksum, assetMediaCreateDto }: assetMediaCreateDto: AssetMediaCreateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMediaResponseDto; + } | { status: 201; data: AssetMediaResponseDto; }>(`/assets${QS.query(QS.explode({ @@ -2462,6 +2486,33 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } +/** + * Delete asset metadata + */ +export function deleteBulkAssetMetadata({ assetMetadataBulkDeleteDto }: { + assetMetadataBulkDeleteDto: AssetMetadataBulkDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/assets/metadata", oazapfts.json({ + ...opts, + method: "DELETE", + body: assetMetadataBulkDeleteDto + }))); +} +/** + * Upsert asset metadata + */ +export function updateBulkAssetMetadata({ assetMetadataBulkUpsertDto }: { + assetMetadataBulkUpsertDto: AssetMetadataBulkUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataBulkResponseDto[]; + }>("/assets/metadata", oazapfts.json({ + ...opts, + method: "PUT", + body: assetMetadataBulkUpsertDto + }))); +} /** * Get random assets */ @@ -2564,7 +2615,7 @@ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { */ export function deleteAssetMetadata({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { ...opts, @@ -2576,7 +2627,7 @@ export function deleteAssetMetadata({ id, key }: { */ export function getAssetMetadataByKey({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -5363,9 +5414,6 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } -export enum AssetMetadataKey { - MobileApp = "mobile-app" -} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91285d6784..9980023d36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 24.10.4 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -109,16 +109,16 @@ importers: version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -169,7 +169,7 @@ importers: version: 18.3.1(react@18.3.1) tailwindcss: specifier: ^3.2.4 - version: 3.4.19(yaml@2.8.2) + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) url: specifier: ^0.11.0 version: 0.11.4 @@ -201,6 +201,9 @@ importers: '@immich/cli': specifier: file:../cli version: link:../cli + '@immich/e2e-auth-server': + specifier: file:../e2e-auth-server + version: link:../e2e-auth-server '@immich/sdk': specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk @@ -216,9 +219,6 @@ importers: '@types/node': specifier: ^24.10.4 version: 24.10.4 - '@types/oidc-provider': - specifier: ^9.0.0 - version: 9.5.0 '@types/pg': specifier: ^8.15.1 version: 8.16.0 @@ -249,15 +249,9 @@ importers: globals: specifier: ^16.0.0 version: 16.5.0 - jose: - specifier: ^5.6.3 - version: 5.10.0 luxon: specifier: ^3.4.4 version: 3.7.2 - oidc-provider: - specifier: ^9.0.0 - version: 9.6.0 pg: specifier: ^8.11.3 version: 8.16.3 @@ -290,7 +284,22 @@ importers: 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.4)(happy-dom@20.0.11)(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)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + e2e-auth-server: + devDependencies: + '@types/oidc-provider': + specifier: ^9.0.0 + version: 9.5.0 + jose: + specifier: ^5.6.3 + version: 5.10.0 + oidc-provider: + specifier: ^9.0.0 + version: 9.6.0 + tsx: + specifier: ^4.20.6 + version: 4.21.0 i18n: devDependencies: @@ -552,7 +561,7 @@ importers: version: 4.8.3 tailwindcss-preset-email: specifier: ^1.4.0 - version: 1.4.1(tailwindcss@3.4.19(yaml@2.8.2)) + version: 1.4.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -655,7 +664,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -694,7 +703,7 @@ importers: version: 7.1.4 tailwindcss: specifier: ^3.4.0 - version: 3.4.19(yaml@2.8.2) + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) testcontainers: specifier: ^11.0.0 version: 11.11.0 @@ -709,10 +718,10 @@ importers: version: 1.5.9(@swc/core@1.15.8(@swc/helpers@0.5.17))(rollup@4.53.4) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -726,8 +735,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.52.0 - version: 0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + specifier: ^0.54.0 + version: 0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -761,9 +770,6 @@ importers: '@zoom-image/svelte': specifier: ^0.3.0 version: 0.3.8(svelte@5.46.1) - async-mutex: - specifier: ^0.5.0 - version: 0.5.0 dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -848,25 +854,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.9.0 - version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(rollup@4.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.1 - version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -890,7 +896,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.2.3 @@ -950,10 +956,10 @@ importers: version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -3078,8 +3084,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.52.0': - resolution: {integrity: sha512-ECQIE5qYNpe7Q5+hifIGUDaRQXBkPOp9dvZaHELWWzAGIhbwG+mUYwMpUgU2TO7fV5u8XU6nHyBuC055zApiWQ==} + '@immich/ui@0.54.0': + resolution: {integrity: sha512-6jvkvKhgsZ7LvspaJkbht/f8W5IRm+vjYkcZecShFAPaxaowbm7io9sO15MpJdIQfPdXg7vwLI527PV3vlBc6A==} peerDependencies: svelte: ^5.0.0 @@ -5620,9 +5626,6 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} - async-mutex@0.5.0: - resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - async@0.2.10: resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} @@ -7422,6 +7425,9 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} @@ -10414,6 +10420,9 @@ packages: resolve-pathname@3.0.0: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -11375,6 +11384,11 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -15084,12 +15098,12 @@ snapshots: dependencies: svelte: 5.46.1 - '@immich/ui@0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)': + '@immich/ui@0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)': dependencies: '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1) luxon: 3.7.2 simple-icons: 16.4.0 svelte: 5.46.1 @@ -16694,29 +16708,29 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(rollup@4.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.53.4)(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 svelte: 5.46.1 svelte-parse-markup: 0.1.5(svelte@5.46.1) - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.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.53.4) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16729,28 +16743,28 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.46.1 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 svelte: 5.46.1 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 svelte: 5.46.1 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + vite: 7.3.0(@types/node@25.0.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.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -16969,12 +16983,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/dom@10.4.1': dependencies: @@ -17000,14 +17014,14 @@ snapshots: dependencies: svelte: 5.46.1 - '@testing-library/svelte@5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/svelte-core': 1.0.0(svelte@5.46.1) svelte: 5.46.1 optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.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.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17594,7 +17608,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17609,11 +17623,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.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17628,7 +17642,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17640,21 +17654,21 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(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.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -18001,10 +18015,6 @@ snapshots: async-lock@1.4.1: {} - async-mutex@0.5.0: - dependencies: - tslib: 2.8.1 - async@0.2.10: {} async@3.2.6: {} @@ -18134,15 +18144,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1): + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1) svelte: 5.46.1 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1) tabbable: 6.3.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -20076,6 +20086,10 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-slugger@1.5.0: {} gl-matrix@3.4.4: {} @@ -22827,12 +22841,13 @@ snapshots: optionalDependencies: postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + tsx: 4.21.0 yaml: 2.8.2 postcss-loader@7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.103.0): @@ -23671,6 +23686,8 @@ snapshots: resolve-pathname@3.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.0 @@ -23779,14 +23796,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1): + runed@0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.46.1 optionalDependencies: - '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -24515,10 +24532,10 @@ snapshots: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + runed: 0.35.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.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.46.1)(vite@7.3.0(@types/node@25.0.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.46.1) style-to-object: 1.0.14 svelte: 5.46.1 transitivePeerDependencies: @@ -24585,21 +24602,21 @@ snapshots: optionalDependencies: tailwind-merge: 3.4.0 - tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(yaml@2.8.2)): + tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(yaml@2.8.2) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) - tailwindcss-mso@2.0.3(tailwindcss@3.4.19(yaml@2.8.2)): + tailwindcss-mso@2.0.3(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(yaml@2.8.2) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) - tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(yaml@2.8.2)): + tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(yaml@2.8.2) - tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(yaml@2.8.2)) - tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(yaml@2.8.2)) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) + tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) - tailwindcss@3.4.19(yaml@2.8.2): + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -24618,7 +24635,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -24900,6 +24917,13 @@ snapshots: tsscmp@1.0.6: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -25214,13 +25238,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25235,13 +25259,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25256,18 +25280,18 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)): + vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -25282,9 +25306,10 @@ snapshots: lightningcss: 1.30.2 sass: 1.97.1 terser: 5.44.1 + tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -25299,21 +25324,22 @@ snapshots: 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.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(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.4)(happy-dom@20.0.11)(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)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25331,8 +25357,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(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.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -25353,11 +25379,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25375,8 +25401,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.4)(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.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -25397,11 +25423,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25419,8 +25445,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.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.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7ec4739ae..f7f22e6f44 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - cli - docs - e2e + - e2e-auth-server - i18n - open-api/typescript-sdk - server diff --git a/server/Dockerfile b/server/Dockerfile index 918658e19f..566eb4c913 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -52,7 +52,7 @@ FROM builder AS plugins ARG TARGETPLATFORM -COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise WORKDIR /usr/src/app COPY ./plugins/mise.toml ./plugins/ diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 843c2a3f3d..d52a40d7dd 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -15,7 +15,7 @@ import { UploadedFiles, UseInterceptors, } from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger'; import { NextFunction, Request, Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { @@ -62,6 +62,16 @@ export class AssetMediaController { required: false, }) @ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto }) + @ApiResponse({ + status: 200, + description: 'Asset is a duplicate', + type: AssetMediaResponseDto, + }) + @ApiResponse({ + status: 201, + description: 'Asset uploaded successfully', + type: AssetMediaResponseDto, + }) @Endpoint({ summary: 'Upload asset', description: 'Uploads a new asset to the server.', @@ -103,6 +113,11 @@ export class AssetMediaController { @Put(':id/original') @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') + @ApiResponse({ + status: 200, + description: 'Asset replaced successfully', + type: AssetMediaResponseDto, + }) @Endpoint({ summary: 'Replace asset', description: 'Replace the asset with new file, without changing its id.', diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 649c80e850..56c9d18049 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -79,6 +79,74 @@ describe(AssetController.name, () => { }); }); + describe('PUT /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test', value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] }); + expect(status).toBe(200); + }); + }); + + describe('DELETE /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test' }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid() }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] }); + expect(status).toBe(204); + }); + }); + describe('PUT /assets/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/assets/123`); @@ -169,12 +237,10 @@ describe(AssetController.name, () => { it('should require each item to have a valid key', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/assets/${factory.uuid()}/metadata`) - .send({ items: [{ key: 'someKey' }] }); + .send({ items: [{ value: { some: 'value' } }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]), - ), + factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']), ); }); @@ -224,16 +290,6 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('key must be one of the following value')]), - ), - ); - }); }); describe('DELETE /assets/:id/metadata/:key', () => { @@ -247,13 +303,5 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]), - ); - }); }); }); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index bcc13fbc06..ba9ec865f9 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -7,6 +7,9 @@ import { AssetBulkUpdateDto, AssetCopyDto, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataRouteParams, AssetMetadataUpsertDto, @@ -120,6 +123,32 @@ export class AssetController { return this.service.copy(auth, dto); } + @Put('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @Endpoint({ + summary: 'Upsert asset metadata', + description: 'Upsert metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + updateBulkAssetMetadata( + @Auth() auth: AuthDto, + @Body() dto: AssetMetadataBulkUpsertDto, + ): Promise { + return this.service.upsertBulkMetadata(auth, dto); + } + + @Delete('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete asset metadata', + description: 'Delete metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise { + return this.service.deleteBulkMetadata(auth, dto); + } + @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) @Endpoint({ diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 755069d827..262e2f9637 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -78,7 +78,7 @@ export class AssetMediaCreateDto extends AssetMediaBase { @Optional() @ValidateNested({ each: true }) @IsArray() - metadata!: AssetMetadataUpsertItemDto[]; + metadata?: AssetMetadataUpsertItemDto[]; @ApiProperty({ type: 'string', format: 'binary', required: false }) [UploadFieldName.SIDECAR_DATA]?: any; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 03d1e31fb9..854c244ba9 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -17,9 +17,9 @@ import { ValidateNested, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; +import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; +import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() @@ -142,8 +142,8 @@ export class AssetMetadataRouteParams { @ValidateUUID() id!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; } export class AssetMetadataUpsertDto { @@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto { } export class AssetMetadataUpsertItemDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; @IsObject() value!: object; } -export class AssetMetadataMobileAppDto { - @IsString() - @Optional() - iCloudId?: string; +export class AssetMetadataBulkUpsertDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkUpsertItemDto) + items!: AssetMetadataBulkUpsertItemDto[]; +} + +export class AssetMetadataBulkUpsertItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; + + @IsObject() + value!: object; +} + +export class AssetMetadataBulkDeleteDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkDeleteItemDto) + items!: AssetMetadataBulkDeleteItemDto[]; +} + +export class AssetMetadataBulkDeleteItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; } export class AssetMetadataResponseDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; value!: object; updatedAt!: Date; } +export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto { + assetId!: string; +} + export class AssetCopyDto { @ValidateUUID() sourceId!: string; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d6a557e2c5..7f811af371 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, - AssetMetadataKey, AssetOrder, AssetType, AssetVisibility, @@ -167,16 +166,14 @@ export class SyncAssetExifV1 { @ExtraModel() export class SyncAssetMetadataV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; value!: object; } @ExtraModel() export class SyncAssetMetadataDeleteV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; } @ExtraModel() diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f25a0798d2..27e40139e1 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -76,6 +76,14 @@ where "assetId" = $1 and "key" = $2 +-- AssetRepository.deleteBulkMetadata +begin +delete from "asset_metadata" +where + "assetId" = $1 + and "key" = $2 +commit + -- AssetRepository.getByDayOfYear with "res" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7db3a76f12..e1d16b8a6a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { anyUuid, @@ -256,7 +257,11 @@ export class AssetRepository { .execute(); } - upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) { + upsertMetadata(id: string, items: Array<{ key: string; value: object }>) { + if (items.length === 0) { + return []; + } + return this.db .insertInto('asset_metadata') .values(items.map((item) => ({ assetId: id, ...item }))) @@ -269,8 +274,21 @@ export class AssetRepository { .execute(); } + upsertBulkMetadata(items: Insertable[]) { + return this.db + .insertInto('asset_metadata') + .values(items) + .onConflict((oc) => + oc + .columns(['assetId', 'key']) + .doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })), + ) + .returning(['assetId', 'key', 'value', 'updatedAt']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getMetadataByKey(assetId: string, key: AssetMetadataKey) { + getMetadataByKey(assetId: string, key: string) { return this.db .selectFrom('asset_metadata') .select(['key', 'value', 'updatedAt']) @@ -280,10 +298,23 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async deleteMetadataByKey(id: string, key: AssetMetadataKey) { + async deleteMetadataByKey(id: string, key: string) { await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); } + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] }) + async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) { + if (items.length === 0) { + return; + } + + await this.db.transaction().execute(async (tx) => { + for (const { assetId, key } of items) { + await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute(); + } + }); + } + create(asset: Insertable) { return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); } diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts index 3b94ce6d1a..16272eacf7 100644 --- a/server/src/schema/tables/asset-metadata-audit.table.ts +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -1,5 +1,4 @@ import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { AssetMetadataKey } from 'src/enum'; import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_metadata_audit') @@ -11,7 +10,7 @@ export class AssetMetadataAuditTable { assetId!: string; @Column({ index: true }) - key!: AssetMetadataKey; + key!: string; @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) deletedAt!: Generated; diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index d529d6ad7b..8a7af1360f 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -32,7 +32,7 @@ export class AssetMetadataTable { assetId!: string; @PrimaryColumn({ type: 'character varying' }) - key!: AssetMetadataKey; + key!: AssetMetadataKey | string; @Column({ type: 'jsonb' }) value!: object; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 2bb8530c1c..5683c6ae15 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -433,7 +433,7 @@ export class AssetMediaService extends BaseService { originalFileName: dto.filename || file.originalName, }); - if (dto.metadata) { + if (dto.metadata?.length) { await this.assetRepository.upsertMetadata(asset.id, dto.metadata); } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c584cf134f..1e776bd256 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -11,6 +11,9 @@ import { AssetCopyDto, AssetJobName, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataUpsertDto, AssetStatsDto, @@ -19,16 +22,7 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; -import { - AssetFileType, - AssetMetadataKey, - AssetStatus, - AssetVisibility, - JobName, - JobStatus, - Permission, - QueueName, -} from 'src/enum'; +import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; @@ -381,12 +375,17 @@ export class AssetService extends BaseService { return this.ocrRepository.getByAssetId(id); } + async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + return this.assetRepository.upsertBulkMetadata(dto.items); + } + async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.upsertMetadata(id, dto.items); } - async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const item = await this.assetRepository.getMetadataByKey(id, key); @@ -396,11 +395,16 @@ export class AssetService extends BaseService { return item; } - async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.deleteMetadataByKey(id, key); } + async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + await this.assetRepository.deleteBulkMetadata(dto.items); + } + async run(auth: AuthDto, dto: AssetJobsDto) { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 82ea2cd1fc..44ca231d8f 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; @@ -68,6 +69,7 @@ import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; +import { UploadFile } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; @@ -179,6 +181,12 @@ export class MediumTestContext { return { asset, result }; } + async newMetadata(dto: Insertable) { + const { assetId, ...item } = dto; + const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]); + return { metadata: dto, result }; + } + async newAssetFile(dto: Insertable) { const result = await this.get(AssetRepository).upsertFile(dto); return { result }; @@ -739,6 +747,17 @@ const loginResponse = (): LoginResponseDto => { }; }; +const uploadFile = (file: Partial = {}) => { + return { + uuid: newUuid(), + checksum: randomBytes(32), + originalPath: '/path/to/file.jpg', + originalName: 'file.jpg', + size: 123_456, + ...file, + }; +}; + export const mediumFactory = { assetInsert, assetFaceInsert, @@ -753,4 +772,5 @@ export const mediumFactory = { loginDetails, loginResponse, tagInsert, + uploadFile, }; diff --git a/server/test/medium/specs/services/asset-media.service.spec.ts b/server/test/medium/specs/services/asset-media.service.spec.ts new file mode 100644 index 0000000000..5089850b6f --- /dev/null +++ b/server/test/medium/specs/services/asset-media.service.spec.ts @@ -0,0 +1,100 @@ +import { Kysely } from 'kysely'; +import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto'; +import { AccessRepository } from 'src/repositories/access.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 { StorageRepository } from 'src/repositories/storage.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { AssetService } from 'src/services/asset.service'; +import { mediumFactory, newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AssetMediaService, { + database: db || defaultDatabase, + real: [AccessRepository, AssetRepository, UserRepository], + mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AssetService.name, () => { + describe('uploadAsset', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + const auth = factory.auth({ user: { id: user.id } }); + const file = mediumFactory.uploadFile(); + + await expect( + sut.uploadAsset( + auth, + { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + }, + file, + ), + ).resolves.toEqual({ + id: expect.any(String), + status: AssetMediaStatus.CREATED, + }); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetCreate', { + asset: expect.objectContaining({ deviceAssetId: 'some-id' }), + }); + }); + + it('should work with an empty metadata list', async () => { + const { sut, ctx } = setup(); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + const auth = factory.auth({ user: { id: user.id } }); + const file = mediumFactory.uploadFile(); + + await expect( + sut.uploadAsset( + auth, + { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + metadata: [], + }, + file, + ), + ).resolves.toEqual({ + id: expect.any(String), + status: AssetMediaStatus.CREATED, + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 661c4f5cdb..d0949c153c 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { AssetFileType, JobName, SharedLinkType } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; @@ -430,4 +430,177 @@ describe(AssetService.name, () => { ); }); }); + + describe('upsertBulkMetadata', () => { + 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 }); + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual( + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }), + ); + }); + + it('should work on conflict', 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.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }); + + // verify existing metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }), + ]); + + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }]; + await sut.upsertBulkMetadata(auth, { items }); + + // verify updated metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }), + ]); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id); + expect(metadata1).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }), + ]); + + const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id); + expect(metadata2).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }), + ]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: AssetMetadataKey.MobileApp, + value: { iCloudId: 'id1' }, + }), + expect.objectContaining({ + key: 'some-other-key', + value: { foo: 'bar' }, + }), + ]), + ); + }); + }); + + describe('deleteBulkMetadata', () => { + 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.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work even if the item does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]); + await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]); + }); + + it('should work with multiple metadata for the same asset', 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.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset.id, key: 'some-other-key' }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]); + }); + + it('should not delete unspecified keys', 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.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }], + }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5ba77ddc2f..4847c84a35 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -44,8 +44,10 @@ export const newAssetRepositoryMock = (): Mocked - import { t } from 'svelte-i18n'; - import { onMount } from 'svelte'; - import { mdiCast, mdiCastConnected } from '@mdi/js'; - import { CastDestinationType, castManager } from '$lib/managers/cast-manager.svelte'; - import { GCastDestination } from '$lib/utils/cast/gcast-destination.svelte'; - import { IconButton } from '@immich/ui'; - - onMount(async () => { - await castManager.initialize(); - }); - - -{#if castManager.availableDestinations.length > 0 && castManager.availableDestinations[0].type === CastDestinationType.GCAST} - void GCastDestination.showCastDialog()} - aria-label={$t('cast')} - /> -{/if} diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte index e0e7e1eff7..ae8d1199e0 100644 --- a/web/src/lib/components/ActionButton.svelte +++ b/web/src/lib/components/ActionButton.svelte @@ -1,4 +1,5 @@ -{#if action.$if?.() ?? true} +{#if icon && isEnabled(action)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/ActionMenuItem.svelte b/web/src/lib/components/ActionMenuItem.svelte new file mode 100644 index 0000000000..d50d50bf0b --- /dev/null +++ b/web/src/lib/components/ActionMenuItem.svelte @@ -0,0 +1,16 @@ + + +{#if icon && isEnabled(action)} + onAction(action)} /> +{/if} diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte index 844c4c0bf8..619d2f6c27 100644 --- a/web/src/lib/components/TableButton.svelte +++ b/web/src/lib/components/TableButton.svelte @@ -10,6 +10,6 @@ const { title, icon, onAction } = $derived(action); -{#if action.$if?.() ?? true} +{#if icon && (action.$if?.() ?? true)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index d5fdb36822..b7fcaa88ec 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,6 +1,6 @@ + {#if sharedLink.allowUpload} { - try { - const updatedAlbum = await addUsersToAlbum({ - id: album.id, - addUsersDto: { - albumUsers, - }, - }); - onUpdate(updatedAlbum); - } catch (error) { - handleError(error, $t('errors.unable_to_add_album_users')); - } - }; - const onAlbumUpdate = (album: AlbumResponseDto) => { onUpdate(album); userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album); diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index df61b5d073..19cc5afa8d 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -5,8 +5,6 @@ import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackRespon type ActionMap = { [AssetAction.ARCHIVE]: { asset: TimelineAsset }; [AssetAction.UNARCHIVE]: { asset: TimelineAsset }; - [AssetAction.FAVORITE]: { asset: TimelineAsset }; - [AssetAction.UNFAVORITE]: { asset: TimelineAsset }; [AssetAction.TRASH]: { asset: TimelineAsset }; [AssetAction.DELETE]: { asset: TimelineAsset }; [AssetAction.RESTORE]: { asset: TimelineAsset }; diff --git a/web/src/lib/components/asset-viewer/actions/close-action.svelte b/web/src/lib/components/asset-viewer/actions/close-action.svelte deleted file mode 100644 index 7b3f525a3a..0000000000 --- a/web/src/lib/components/asset-viewer/actions/close-action.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts index 126beead9c..8ba3432464 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts +++ b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts @@ -7,6 +7,13 @@ import DeleteAction from './delete-action.svelte'; let asset: AssetResponseDto; describe('DeleteAction component', () => { + beforeEach(() => { + vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any }; + }); + }); + describe('given an asset which is not trashed yet', () => { beforeEach(() => { asset = assetFactory.build({ isTrashed: false }); diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.svelte b/web/src/lib/components/asset-viewer/actions/delete-action.svelte index be9c9ccf9c..24ef7d941f 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte @@ -1,15 +1,14 @@ trashOrDelete(asset.isTrashed) }, + { shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() }, { shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) }, ]} /> @@ -75,13 +69,7 @@ color="secondary" shape="round" variant="ghost" - icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline} - aria-label={asset.isTrashed ? $t('permanently_delete') : $t('delete')} - onclick={() => trashOrDelete(asset.isTrashed)} + icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline} + aria-label={forceDefault ? $t('permanently_delete') : $t('delete')} + onclick={() => trashOrDelete()} /> - -{#if showConfirmModal} - - (showConfirmModal = false)} onConfirm={deleteAsset} /> - -{/if} diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte deleted file mode 100644 index f790569703..0000000000 --- a/web/src/lib/components/asset-viewer/actions/download-action.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - -{#if !menuItem} - -{:else} - -{/if} diff --git a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte deleted file mode 100644 index ba23570d36..0000000000 --- a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte deleted file mode 100644 index ee09c2976b..0000000000 --- a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - onClick(!isPlaying)} -/> diff --git a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte deleted file mode 100644 index 99b6c1dcde..0000000000 --- a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts index 957c9a17d2..1c802b0dce 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts @@ -32,8 +32,14 @@ describe('AssetViewerNavBar component', () => { vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })), ); vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { smartSearch: true } } as any }; + return { + featureFlagsManager: { + init: vi.fn(), + loadFeatureFlags: vi.fn(), + value: { trash: true, smartSearch: true }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }; }); }); 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 43fe6978e5..60bde6e114 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,16 +1,13 @@ + +
- {#if onClose} - - {/if} +
-
- +
+ - {#if asset.isOffline} - assetViewerManager.toggleDetailPanel()} - aria-label={$t('asset_offline')} - /> - {/if} - {#if asset.livePhotoVideoId} - {@render motionPhoto?.()} - {/if} + + + + {#if asset.type === AssetTypeEnum.Image}
{/if} @@ -458,8 +459,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} haveFadeTransition={false} @@ -483,7 +483,7 @@ {:else} {#key asset.id} {#if asset.type === AssetTypeEnum.Image} - {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} + {#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId} navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - onVideoEnded={() => (shouldPlayMotionPhoto = false)} + onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} {playOriginalVideo} /> {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath @@ -504,8 +504,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} @@ -591,7 +590,7 @@ dimmed={stackedAsset.id !== asset.id} asset={toTimelineAsset(stackedAsset)} onClick={() => { - asset = stackedAsset; + cursor.current = stackedAsset; previewStackedAsset = undefined; }} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} 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 b41891de82..a1ffd8441b 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -13,17 +13,16 @@ let { asset, isOwner }: Props = $props(); - let currentDescription = asset.exifInfo?.description ?? ''; - let draftDescription = $state(currentDescription); + let currentDescription = $derived(asset.exifInfo?.description ?? ''); + let description = $derived(currentDescription); const handleFocusOut = async () => { - if (draftDescription === currentDescription) { + if (description === currentDescription) { return; } try { - await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } }); + await updateAsset({ id: asset.id, updateAssetDto: { description } }); toastManager.success($t('asset_description_updated')); - currentDescription = draftDescription; } catch (error) { handleError(error, $t('cannot_update_the_description')); } @@ -33,7 +32,7 @@ {#if isOwner}