diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index f7e9ad5731..607c08d860 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0 + uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 with: filters: | mobile: diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 1483b4312b..51e023056d 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -34,7 +34,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index 8470e0e18c..ba360b50dc 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -35,7 +35,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:d8ae47cf2e6cf4e2559bd57a60b73674fe44f897cba2c2bddff2987a05be10a4 + image: ghcr.io/immich-app/mdq:main@sha256:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723 outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2946f33783..2567773f46 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 + uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -63,7 +63,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 + uses: github/codeql-action/autobuild@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -76,6 +76,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 + uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7175dc0a89..8417469696 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0 + uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 with: filters: | server: @@ -124,7 +124,7 @@ jobs: tag-suffix: '-rocm' platforms: linux/amd64 runner-mapping: '{"linux/amd64": "mich"}' - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@94429dca83901d5df2515d33427cc8d7c4a34f5e # multi-runner-build-workflow-v1.0.0 permissions: contents: read actions: read @@ -147,7 +147,7 @@ jobs: name: Build and Push Server needs: pre-job if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }} - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@946acac326940f8badf09ccf591d9cb345d6a689 # multi-runner-build-workflow-v0.2.1 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@94429dca83901d5df2515d33427cc8d7c4a34f5e # multi-runner-build-workflow-v1.0.0 permissions: contents: read actions: read diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 1a29bebe47..7d7a9d7ba5 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0 + uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 with: filters: | docs: @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 6643c5abfd..f0742917d8 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -150,7 +150,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 + uses: gruntwork-io/terragrunt-action@95fc057922e3c3d4cc021a81a213f088f333ddef # v3.0.2 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -165,7 +165,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 + uses: gruntwork-io/terragrunt-action@95fc057922e3c3d4cc021a81a213f088f333ddef # v3.0.2 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -199,7 +199,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 + uses: gruntwork-io/terragrunt-action@95fc057922e3c3d4cc021a81a213f088f333ddef # v3.0.2 with: tg_version: '0.58.12' tofu_version: '1.7.1' diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index c4b9a9fff3..ad3accab54 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -25,7 +25,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8 + uses: gruntwork-io/terragrunt-action@95fc057922e3c3d4cc021a81a213f088f333ddef # v3.0.2 with: tg_version: '0.58.12' tofu_version: '1.7.1' diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 1c57828b0c..dd61764bbc 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -29,7 +29,7 @@ jobs: persist-credentials: true - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a6fa5c802f..c11f979518 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -62,10 +62,10 @@ jobs: ref: main - name: Install uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -128,7 +128,7 @@ jobs: name: release-apk-signed - name: Create draft release - uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 with: draft: true tag_name: ${{ env.IMMICH_VERSION }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index a0d89be598..6d768efaaf 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -21,7 +21,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 # Setup .npmrc file to publish to npm - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 3e7223be74..8a301e9e34 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0 + uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 with: filters: | mobile: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3ed57a59ba..f89921b895 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0 + uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 with: filters: | i18n: @@ -60,7 +60,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -97,7 +97,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -137,7 +137,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -172,7 +172,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -209,7 +209,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -240,7 +240,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -281,7 +281,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -320,7 +320,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -352,7 +352,7 @@ jobs: persist-credentials: false submodules: 'recursive' - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -400,7 +400,7 @@ jobs: persist-credentials: false submodules: 'recursive' - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -470,7 +470,7 @@ jobs: with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # with: @@ -507,7 +507,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -544,7 +544,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -599,7 +599,7 @@ jobs: with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml index d7deb244f9..f769b37a70 100644 --- a/.github/workflows/weblate-lock.yml +++ b/.github/workflows/weblate-lock.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0 + uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 with: filters: | i18n: diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index f13814d91f..5c9e47b010 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -6,6 +6,10 @@ Users can deploy a custom reverse proxy that forwards requests to Immich. This w Immich does not support being served on a sub-path such as `location /immich {`. It has to be served on the root path of a (sub)domain. ::: +:::info +If your reverse proxy uses the [Let's Encrypt](https://letsencrypt.org/) [http-01 challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge), you may want to verify that the Immich well-known endpoint (`/.well-known/immich`) gets correctly routed to Immich, otherwise it will likely be routed elsewhere and the mobile app may run into connection issues. +::: + ### Nginx example config Below is an example config for nginx. Make sure to set `public_url` to the front-facing URL of your instance, and `backend_url` to the path of the Immich server. @@ -37,29 +41,14 @@ server { location / { proxy_pass http://:2283; } + + # useful when using Let's Encrypt http-01 challenge + # location = /.well-known/immich { + # proxy_pass http://:2283; + # } } ``` -#### Compatibility with Let's Encrypt - -In the event that your nginx configuration includes a section for Let's Encrypt, it's likely that you have a segment similar to the following: - -```nginx -location ~ /.well-known { - ... -} -``` - -This particular `location` directive can inadvertently prevent mobile clients from reaching the `/.well-known/immich` path, which is crucial for discovery. Usual error message for this case is: "Your app major version is not compatible with the server". To remedy this, you should introduce an additional location block specifically for this path, ensuring that requests are correctly proxied to the Immich server: - -```nginx -location = /.well-known/immich { - proxy_pass http://:2283; -} -``` - -By doing so, you'll maintain the functionality of Let's Encrypt while allowing mobile clients to access the necessary Immich path without obstruction. - ### Caddy example config As an alternative to nginx, you can also use [Caddy](https://caddyserver.com/) as a reverse proxy (with automatic HTTPS configuration). Below is an example config. diff --git a/docs/docs/community-guides.mdx b/docs/docs/community-guides.mdx deleted file mode 100644 index 505ec93e77..0000000000 --- a/docs/docs/community-guides.mdx +++ /dev/null @@ -1,12 +0,0 @@ -# Community Guides - -This page lists community guides that are written around Immich, but not officially supported by the development team. - -:::warning -This list comes with no guarantees about security, performance, reliability, or accuracy. Use at your own risk. -::: - -import CommunityGuides from '../src/components/community-guides.tsx'; -import React from 'react'; - - diff --git a/docs/docs/community-projects.mdx b/docs/docs/community-projects.mdx deleted file mode 100644 index eb41090cd6..0000000000 --- a/docs/docs/community-projects.mdx +++ /dev/null @@ -1,12 +0,0 @@ -# Community Projects - -This page lists community projects that are built around Immich, but not officially supported by the development team. - -:::warning -This list comes with no guarantees about security, performance, reliability, or accuracy. Use at your own risk. -::: - -import CommunityProjects from '../src/components/community-projects.tsx'; -import React from 'react'; - - diff --git a/docs/docs/features/command-line-interface.md b/docs/docs/features/command-line-interface.md index 436b499e50..2b35d62ce8 100644 --- a/docs/docs/features/command-line-interface.md +++ b/docs/docs/features/command-line-interface.md @@ -182,7 +182,7 @@ For example to get a list of files that would be uploaded for further processing: ```bash -immich upload --dry-run . | tail -n +4 | jq .newFiles[] +immich upload --dry-run . | tail -n +6 | jq .newFiles[] ``` ### Obtain the API Key diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index 7f92c25449..685f23932c 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -57,6 +57,22 @@ You do not need to redo any machine learning jobs after enabling hardware accele - Ensure the server's kernel version is new enough to use the device for hardware acceleration. - Expect higher RAM usage when using OpenVINO compared to CPU processing. +#### OpenVINO-WSL + +- Ensure your container can access the /dev/dri directory, you can verify this by doing `docker exec -t immich_machine_learning ls -la /dev/dri`. If this is not the case execute `getent group render` and `getent group video` on the WSL host, then add those groups to hwaccel.ml.yaml + ```yaml + openvino-wsl: + devices: + - /dev/dri:/dev/dri + - /dev/dxg:/dev/dxg + volumes: + - /dev/bus/usb:/dev/bus/usb + - /usr/lib/wsl:/usr/lib/wsl + group_add: + - 44 # Replace this number with the number you found with getent group video + - 992 # Replace this number with the number you found with getent group render + ``` + #### RKNN - You must have a supported Rockchip SoC: only RK3566, RK3568, RK3576 and RK3588 are supported at this moment. diff --git a/docs/docs/features/mobile-app.mdx b/docs/docs/features/mobile-app.mdx index 82a2976b41..2d34507a26 100644 --- a/docs/docs/features/mobile-app.mdx +++ b/docs/docs/features/mobile-app.mdx @@ -3,7 +3,6 @@ import { mdiCloudOffOutline, mdiCloudCheckOutline } from '@mdi/js'; import MobileAppDownload from '/docs/partials/_mobile-app-download.md'; import MobileAppLogin from '/docs/partials/_mobile-app-login.md'; import MobileAppBackup from '/docs/partials/_mobile-app-backup.md'; -import { cloudDonePath, cloudOffPath } from '@site/src/components/svg-paths'; # Mobile App diff --git a/docs/docs/partials/_mobile-app-download.md b/docs/docs/partials/_mobile-app-download.md index 72a3053440..31cf62bf81 100644 --- a/docs/docs/partials/_mobile-app-download.md +++ b/docs/docs/partials/_mobile-app-download.md @@ -1,5 +1,6 @@ The mobile app can be downloaded from the following places: +- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities). - [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich) - [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652) - [F-Droid](https://f-droid.org/packages/app.alextran.immich) diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx deleted file mode 100644 index 08c8e096d9..0000000000 --- a/docs/src/components/community-guides.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import Link from '@docusaurus/Link'; -import React from 'react'; - -interface CommunityGuidesProps { - title: string; - description: string; - url: string; -} - -const guides: CommunityGuidesProps[] = [ - { - title: 'Cloudflare Tunnels with SSO/OAuth', - description: `Setting up Cloudflare Tunnels and a SaaS App for Immich.`, - url: 'https://github.com/immich-app/immich/discussions/8299', - }, - { - title: 'Database backup in TrueNAS', - description: `Create a database backup with pgAdmin in TrueNAS.`, - url: 'https://github.com/immich-app/immich/discussions/8809', - }, - { - title: 'Unraid backup scripts', - description: `Back up your assets in Unraid with a pre-prepared script.`, - url: 'https://github.com/immich-app/immich/discussions/8416', - }, - { - title: 'Sync folders with albums', - description: `synchronize folders in imported library with albums having the folders name.`, - url: 'https://github.com/immich-app/immich/discussions/3382', - }, - { - title: 'Immich Podman Quadlets Handbook', - description: - 'A rewrite of the original Immich Docker Compose file using Podman Quadlets, with a set of extra guides in the repository’s wiki.', - url: 'https://github.com/linux-universe/immich-podman-quadlets/blob/main/README.md', - }, - { - title: 'Podman/Quadlets Install', - description: 'Documentation for simple podman setup using quadlets.', - url: 'https://github.com/tbelway/immich-podman-quadlets/blob/main/docs/install/podman-quadlet.md', - }, - { - title: 'Google Photos import + albums', - description: 'Import your Google Photos files into Immich and add your albums.', - url: 'https://github.com/immich-app/immich/discussions/1340', - }, - { - title: 'Access Immich with custom domain', - description: 'Access your local Immich installation over the internet using your own domain.', - url: 'https://github.com/ppr88/immich-guides/blob/main/open-immich-custom-domain.md', - }, - { - title: 'Nginx caching map server', - description: 'Increase privacy by using nginx as a caching proxy in front of a map tile server.', - url: 'https://github.com/pcouy/pcouy.github.io/blob/main/_posts/2024-08-30-proxying-a-map-tile-server-for-increased-privacy.md', - }, - { - title: 'fail2ban setup instructions', - description: 'How to configure an existing fail2ban installation to block incorrect login attempts.', - url: 'https://github.com/immich-app/immich/discussions/3243#discussioncomment-6681948', - }, - { - title: 'Immich remote access with NordVPN Meshnet', - description: 'Access Immich with an end-to-end encrypted connection.', - url: 'https://meshnet.nordvpn.com/how-to/remote-files-media-access/immich-remote-access', - }, - { - title: 'Trust Self Signed Certificates with Immich - OAuth Setup', - description: - 'Set up Certificate Authority trust with Immich, and your private OAuth2/OpenID service, while using a private CA for HTTPS commication.', - url: 'https://github.com/immich-app/immich/discussions/18614', - }, -]; - -function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element { - return ( -
-
-

- {title} -

- -

{description}

-

- {url} -

-
-
- - View Guide - -
-
- ); -} - -export default function CommunityGuides(): JSX.Element { - return ( -
- {guides.map((guides) => ( - - ))} -
- ); -} diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx deleted file mode 100644 index 6b74ae7ad8..0000000000 --- a/docs/src/components/community-projects.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import Link from '@docusaurus/Link'; -import React from 'react'; - -interface CommunityProjectProps { - title: string; - description: string; - url: string; -} - -const projects: CommunityProjectProps[] = [ - { - title: 'immich-go', - description: `An alternative to the immich-CLI that doesn't depend on nodejs. It specializes in importing Google Photos Takeout archives.`, - url: 'https://github.com/simulot/immich-go', - }, - { - title: 'ImmichFrame', - description: 'Run an Immich slideshow in a photo frame.', - url: 'https://github.com/3rob3/ImmichFrame', - }, - { - title: 'API Album Sync', - description: 'A Python script to sync folders as albums.', - url: 'https://git.orenit.solutions/open/immichalbumpull', - }, - { - title: 'Immich-Tools', - description: 'Provides scripts for handling problems on the repair page.', - url: 'https://github.com/clumsyCoder00/Immich-Tools', - }, - { - title: 'Lightroom Publisher: mi.Immich.Publisher', - description: 'Lightroom plugin to publish photos from Lightroom collections to Immich albums.', - url: 'https://github.com/midzelis/mi.Immich.Publisher', - }, - { - title: 'Lightroom Immich Plugin: lrc-immich-plugin', - description: - 'Lightroom plugin to publish, export photos from Lightroom to Immich. Import from Immich to Lightroom is also supported.', - url: 'https://blog.fokuspunk.de/lrc-immich-plugin/', - }, - { - title: 'Immich-Tiktok-Remover', - description: 'Script to search for and remove TikTok videos from your Immich library.', - url: 'https://github.com/mxc2/immich-tiktok-remover', - }, - { - title: 'Immich Android TV', - description: 'Unofficial Immich Android TV app.', - url: 'https://github.com/giejay/Immich-Android-TV', - }, - { - title: 'Create albums from folders', - description: 'A Python script to create albums based on the folder structure of an external library.', - url: 'https://github.com/Salvoxia/immich-folder-album-creator', - }, - { - title: 'Powershell Module PSImmich', - description: 'Powershell Module for the Immich API', - url: 'https://github.com/hanpq/PSImmich', - }, - { - title: 'Immich Distribution', - description: 'Snap package for easy install and zero-care auto updates of Immich. Self-hosted photo management.', - url: 'https://immich-distribution.nsg.cc', - }, - { - title: 'Immich Kiosk', - description: 'Lightweight slideshow to run on kiosk devices and browsers.', - url: 'https://github.com/damongolding/immich-kiosk', - }, - { - title: 'Immich Power Tools', - description: 'Power tools for organizing your immich library.', - url: 'https://github.com/varun-raj/immich-power-tools', - }, - { - title: 'Immich Public Proxy', - description: - 'Share your Immich photos and albums in a safe way without exposing your Immich instance to the public.', - url: 'https://github.com/alangrainger/immich-public-proxy', - }, - { - title: 'Immich Kodi', - description: 'Unofficial Kodi plugin for Immich.', - url: 'https://github.com/vladd11/immich-kodi', - }, - { - title: 'Immich Downloader', - description: 'Downloads a configurable number of random photos based on people or album ID.', - url: 'https://github.com/jon6fingrs/immich-dl', - }, - { - title: 'Immich Upload Optimizer', - description: 'Automatically optimize files uploaded to Immich in order to save storage space', - url: 'https://github.com/miguelangel-nubla/immich-upload-optimizer', - }, - { - title: 'Immich Machine Learning Load Balancer', - description: 'Speed up your machine learning by load balancing your requests to multiple computers', - url: 'https://github.com/apetersson/immich_ml_balancer', - }, - { - title: 'Immich Drop Uploader', - description: 'A tiny, zero-login web app for collecting photos/videos from anyone into your Immich server.', - url: 'https://github.com/Nasogaa/immich-drop', - }, - { - title: 'Immich Birthday Sync', - description: 'Bulk-upload and -download birthdays, with CardDAV sync support', - url: 'https://github.com/sid3windr/immich-birthday', - }, - { - title: 'Immich Stack', - description: 'Auto-stack photos with identical filenames and differing extensions (i.e. JPG+RAW)', - url: 'https://github.com/sid3windr/immich-stack', - }, - { - title: 'Immich Stack', - description: 'Automatically groups similar photos into stacks within the Immich photo management system.', - url: 'https://github.com/Majorfi/immich-stack/', - }, -]; - -function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { - return ( -
-
-

- {title} -

- -

{description}

-

- {url} -

-
-
- - View Link - -
-
- ); -} - -export default function CommunityProjects(): JSX.Element { - return ( -
- {projects.map((project) => ( - - ))} -
- ); -} diff --git a/docs/src/components/svg-paths.ts b/docs/src/components/svg-paths.ts deleted file mode 100644 index 0903392307..0000000000 --- a/docs/src/components/svg-paths.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const discordPath = - 'M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z'; -export const discordViewBox = '0 0 126.644 96'; diff --git a/docs/static/_redirects b/docs/static/_redirects index ecbdf19303..ce4b670246 100644 --- a/docs/static/_redirects +++ b/docs/static/_redirects @@ -27,8 +27,10 @@ /administration/password-login /administration/system-settings 307 /features/search /features/searching 307 /features/smart-search /features/searching 307 -/guides/api-album-sync /community-projects 307 -/guides/remove-offline-files /community-projects 307 +/guides/api-album-sync https://awesome.immich.app/ 307 +/guides/remove-offline-files https://awesome.immich.app/ 307 +/community-guides https://awesome.immich.app/ 307 +/community-projects https://awesome.immich.app/ 307 /overview/introduction /overview/quick-start 307 /overview/welcome /overview/quick-start 307 /docs/* /:splat 307 diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index 173131ec5e..a14a177917 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -38,6 +38,7 @@ test.describe('Registration', () => { await page.getByRole('button', { name: 'User Privacy' }).click(); await page.getByRole('button', { name: 'Storage Template' }).click(); await page.getByRole('button', { name: 'Backups' }).click(); + await page.getByRole('button', { name: 'Mobile App' }).click(); await page.getByRole('button', { name: 'Done' }).click(); // success @@ -85,6 +86,7 @@ test.describe('Registration', () => { await page.getByRole('button', { name: 'Theme' }).click(); await page.getByRole('button', { name: 'Language' }).click(); await page.getByRole('button', { name: 'User Privacy' }).click(); + await page.getByRole('button', { name: 'Mobile App' }).click(); await page.getByRole('button', { name: 'Done' }).click(); // success diff --git a/i18n/en.json b/i18n/en.json index 0d9e52681c..d265c9b9d8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -211,6 +211,8 @@ "notification_email_ignore_certificate_errors_description": "Ignore TLS certificate validation errors (not recommended)", "notification_email_password_description": "Password to use when authenticating with the email server", "notification_email_port_description": "Port of the email server (e.g 25, 465, or 587)", + "notification_email_secure": "SMTPS", + "notification_email_secure_description": "Use SMTPS (SMTP over TLS)", "notification_email_sent_test_email_button": "Send test email and save", "notification_email_setting_description": "Settings for sending email notifications", "notification_email_test_email": "Send test email", @@ -466,9 +468,11 @@ "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.", "api_key_empty": "Your API Key name shouldn't be empty", "api_keys": "API Keys", + "app_architecture_variant": "Variant (Architecture)", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "app_download_links": "App Download Links", "app_settings": "App Settings", "appears_in": "Appears in", "apply_count": "Apply ({count, number})", @@ -1346,6 +1350,8 @@ "minute": "Minute", "minutes": "Minutes", "missing": "Missing", + "mobile_app": "Mobile App", + "mobile_app_download_onboarding_note": "You can access these options again from the Utilities page.", "model": "Model", "month": "Month", "monthly_title_text_date_format": "MMMM y", @@ -1364,6 +1370,8 @@ "my_albums": "My albums", "name": "Name", "name_or_nickname": "Name or nickname", + "navigate": "Navigate", + "navigate_to_time": "Navigate to Time", "network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_videos_upload": "Use cellular data to backup videos", "network_requirements": "Network Requirements", @@ -1373,6 +1381,7 @@ "never": "Never", "new_album": "New Album", "new_api_key": "New API Key", + "new_date_range": "New date range", "new_password": "New password", "new_person": "New person", "new_pin_code": "New PIN code", @@ -1423,6 +1432,8 @@ "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", + "obtainium_configurator": "Obtainium Configurator", + "obtainium_configurator_instructions": "Please create an API key and select a variant to create your Obtainium configuration link.", "official_immich_resources": "Official Immich Resources", "offline": "Offline", "offset": "Offset", diff --git a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart index bdaaa426c5..46307046b4 100644 --- a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart @@ -17,6 +17,7 @@ class SystemConfigSmtpTransportDto { required this.ignoreCert, required this.password, required this.port, + required this.secure, required this.username, }); @@ -30,6 +31,8 @@ class SystemConfigSmtpTransportDto { /// Maximum value: 65535 num port; + bool secure; + String username; @override @@ -38,6 +41,7 @@ class SystemConfigSmtpTransportDto { other.ignoreCert == ignoreCert && other.password == password && other.port == port && + other.secure == secure && other.username == username; @override @@ -47,10 +51,11 @@ class SystemConfigSmtpTransportDto { (ignoreCert.hashCode) + (password.hashCode) + (port.hashCode) + + (secure.hashCode) + (username.hashCode); @override - String toString() => 'SystemConfigSmtpTransportDto[host=$host, ignoreCert=$ignoreCert, password=$password, port=$port, username=$username]'; + String toString() => 'SystemConfigSmtpTransportDto[host=$host, ignoreCert=$ignoreCert, password=$password, port=$port, secure=$secure, username=$username]'; Map toJson() { final json = {}; @@ -58,6 +63,7 @@ class SystemConfigSmtpTransportDto { json[r'ignoreCert'] = this.ignoreCert; json[r'password'] = this.password; json[r'port'] = this.port; + json[r'secure'] = this.secure; json[r'username'] = this.username; return json; } @@ -75,6 +81,7 @@ class SystemConfigSmtpTransportDto { ignoreCert: mapValueOfType(json, r'ignoreCert')!, password: mapValueOfType(json, r'password')!, port: num.parse('${json[r'port']}'), + secure: mapValueOfType(json, r'secure')!, username: mapValueOfType(json, r'username')!, ); } @@ -127,6 +134,7 @@ class SystemConfigSmtpTransportDto { 'ignoreCert', 'password', 'port', + 'secure', 'username', }; } diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 1ce33b96e6..43292089d7 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -15,7 +15,7 @@ function dart { patch --no-backup-if-mismatch -u api.mustache =0.4.7'} hasBin: true - happy-dom@20.0.0: - resolution: {integrity: sha512-GkWnwIFxVGCf2raNrxImLo397RdGhLapj5cT3R2PT7FwL62Ze1DROhzmYW7+J3p9105DYMVenEejEbnq5wA37w==} + happy-dom@20.0.2: + resolution: {integrity: sha512-pYOyu624+6HDbY+qkjILpQGnpvZOusItCk+rvF5/V+6NkcgTKnbOldpIy22tBnxoaLtlM9nXgoqAcW29/B7CIw==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -15689,29 +15689,29 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.46.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.46.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: - '@sveltejs/kit': 2.46.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + '@sveltejs/kit': 2.46.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) - '@sveltejs/enhanced-img@0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(rollup@4.52.4)(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@sveltejs/enhanced-img@0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(rollup@4.52.4)(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) magic-string: 0.30.19 sharp: 0.34.4 svelte: 5.39.11 svelte-parse-markup: 0.1.5(svelte@5.39.11) - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) vite-imagetools: 8.0.0(rollup@4.52.4) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.46.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@sveltejs/kit@2.46.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -15724,28 +15724,28 @@ snapshots: set-cookie-parser: 2.7.1 sirv: 3.0.2 svelte: 5.39.11 - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) optionalDependencies: '@opentelemetry/api': 1.9.0 - '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) debug: 4.4.3 svelte: 5.39.11 - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)))(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.19 svelte: 5.39.11 - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - vitefu: 1.1.1(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vitefu: 1.1.1(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) transitivePeerDependencies: - supports-color @@ -15967,12 +15967,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.14 '@tailwindcss/oxide-win32-x64-msvc': 4.1.14 - '@tailwindcss/vite@4.1.14(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@tailwindcss/vite@4.1.14(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.1.14 '@tailwindcss/oxide': 4.1.14 tailwindcss: 4.1.14 - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) '@testing-library/dom@10.4.0': dependencies: @@ -15994,13 +15994,13 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte@5.2.8(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.1)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@testing-library/svelte@5.2.8(svelte@5.39.11)(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@testing-library/dom': 10.4.0 svelte: 5.39.11 optionalDependencies: - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.1)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': dependencies: @@ -16309,7 +16309,7 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@20.19.2': + '@types/node@20.19.21': dependencies: undici-types: 6.21.0 @@ -16317,7 +16317,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.7.1': + '@types/node@24.7.2': dependencies: undici-types: 7.14.0 optional: true @@ -16567,7 +16567,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16582,11 +16582,11 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.1)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16601,7 +16601,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.1)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -16621,13 +16621,13 @@ snapshots: optionalDependencies: vite: 7.1.9(@types/node@22.18.10)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - '@vitest/mocker@3.2.4(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -19136,9 +19136,9 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.0.0: + happy-dom@20.0.2: dependencies: - '@types/node': 20.19.2 + '@types/node': 20.19.21 '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 @@ -24013,13 +24013,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -24061,7 +24061,7 @@ snapshots: terser: 5.43.1 yaml: 2.8.1 - vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): + vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3) @@ -24070,22 +24070,22 @@ snapshots: rollup: 4.52.4 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.7.1 + '@types/node': 24.7.2 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.1 terser: 5.43.1 yaml: 2.8.1 - vitefu@1.1.1(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)): + vitefu@1.1.1(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)): optionalDependencies: - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -24113,7 +24113,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.18.10 - happy-dom: 20.0.0 + happy-dom: 20.0.2 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -24129,7 +24129,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.10)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -24157,7 +24157,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.18.10 - happy-dom: 20.0.0 + happy-dom: 20.0.2 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -24173,11 +24173,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.1)(happy-dom@20.0.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(happy-dom@20.0.2)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24195,13 +24195,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.1.9(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.7.1 - happy-dom: 20.0.0 + '@types/node': 24.7.2 + happy-dom: 20.0.2 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti diff --git a/server/src/config.ts b/server/src/config.ts index 66c03450fa..1a88b0a2d3 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -159,6 +159,7 @@ export interface SystemConfig { ignoreCert: boolean; host: string; port: number; + secure: boolean; username: string; password: string; }; @@ -356,6 +357,7 @@ export const defaults = Object.freeze({ ignoreCert: false, host: '', port: 587, + secure: false, username: '', password: '', }, diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index f60f2a8824..b3fecf2f8f 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -212,7 +212,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset fileModifiedAt: entity.fileModifiedAt, localDateTime: entity.localDateTime, updatedAt: entity.updatedAt, - isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false, + isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite, isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, visibility: entity.visibility, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 1facc6c331..f12846fc9a 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -463,6 +463,9 @@ class SystemConfigSmtpTransportDto { @Max(65_535) port!: number; + @ValidateBoolean() + secure!: boolean; + @IsString() username!: string; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 1283ff0a66..e3a0eb8c06 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -296,7 +296,8 @@ with "asset"."duration", "asset"."id", "asset"."visibility", - "asset"."isFavorite", + asset."isFavorite" + and asset."ownerId" = $1 as "isFavorite", asset.type = 'IMAGE' as "isImage", asset."deletedAt" is not null as "isTrashed", "asset"."livePhotoVideoId", @@ -341,14 +342,14 @@ with where "stacked"."stackId" = "asset"."stackId" and "stacked"."deletedAt" is null - and "stacked"."visibility" = $1 + and "stacked"."visibility" = $2 group by "stacked"."stackId" ) as "stacked_assets" on true where "asset"."deletedAt" is null and "asset"."visibility" in ('archive', 'timeline') - and date_trunc('MONTH', "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' = $2 + and date_trunc('MONTH', "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' = $3 and not exists ( select from diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 5c3bd8996c..73d3a485dd 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -4,6 +4,7 @@ import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { 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 { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -589,9 +590,9 @@ export class AssetRepository { } @GenerateSql({ - params: [DummyValue.TIME_BUCKET, { withStacked: true }], + params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }], }) - getTimeBucket(timeBucket: string, options: TimeBucketOptions) { + getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) { const query = this.db .with('cte', (qb) => qb @@ -601,7 +602,7 @@ export class AssetRepository { 'asset.duration', 'asset.id', 'asset.visibility', - 'asset.isFavorite', + sql`asset."isFavorite" and asset."ownerId" = ${auth.user.id}`.as('isFavorite'), sql`asset.type = 'IMAGE'`.as('isImage'), sql`asset."deletedAt" is not null`.as('isTrashed'), 'asset.livePhotoVideoId', diff --git a/server/src/repositories/email.repository.ts b/server/src/repositories/email.repository.ts index 78c89b4a9d..1bc4f0981a 100644 --- a/server/src/repositories/email.repository.ts +++ b/server/src/repositories/email.repository.ts @@ -23,6 +23,7 @@ export type SendEmailOptions = { export type SmtpOptions = { host: string; port?: number; + secure?: boolean; username?: string; password?: string; ignoreCert?: boolean; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index a01b46f3bd..d2e1aa08c8 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -31,6 +31,7 @@ import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; @@ -79,6 +80,7 @@ export const repositories = [ SessionRepository, ServerInfoRepository, SharedLinkRepository, + SharedLinkAssetRepository, StackRepository, StorageRepository, SyncRepository, diff --git a/server/src/repositories/shared-link-asset.repository.ts b/server/src/repositories/shared-link-asset.repository.ts new file mode 100644 index 0000000000..45085c4a8d --- /dev/null +++ b/server/src/repositories/shared-link-asset.repository.ts @@ -0,0 +1,18 @@ +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/schema'; + +export class SharedLinkAssetRepository { + constructor(@InjectKysely() private db: Kysely) {} + + async remove(sharedLinkId: string, assetsId: string[]) { + const deleted = await this.db + .deleteFrom('shared_link_asset') + .where('shared_link_asset.sharedLinksId', '=', sharedLinkId) + .where('shared_link_asset.assetsId', 'in', assetsId) + .returning('assetsId') + .execute(); + + return deleted.map((row) => row.assetsId); + } +} diff --git a/server/src/schema/migrations/1744910873969-InitialMigration.ts b/server/src/schema/migrations/1744910873969-InitialMigration.ts index 53a55d860e..b703a47536 100644 --- a/server/src/schema/migrations/1744910873969-InitialMigration.ts +++ b/server/src/schema/migrations/1744910873969-InitialMigration.ts @@ -16,7 +16,9 @@ export async function up(db: Kysely): Promise { rows: [lastMigration], } = await lastMigrationSql.execute(db); if (lastMigration?.name !== 'AddMissingIndex1744910873956') { - throw new Error('Invalid upgrade path. For more information, see https://immich.app/errors#typeorm-upgrade'); + throw new Error( + 'Invalid upgrade path. For more information, see https://docs.immich.app/errors/#typeorm-upgrade', + ); } logger.log('Database has up to date TypeORM migrations, skipping initial Kysely migration'); return; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6b85c3ec6c..5a2dd42c3c 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -38,6 +38,7 @@ import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; @@ -89,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ ServerInfoRepository, SessionRepository, SharedLinkRepository, + SharedLinkAssetRepository, StackRepository, StorageRepository, SyncRepository, @@ -141,6 +143,7 @@ export class BaseService { protected serverInfoRepository: ServerInfoRepository, protected sessionRepository: SessionRepository, protected sharedLinkRepository: SharedLinkRepository, + protected sharedLinkAssetRepository: SharedLinkAssetRepository, protected stackRepository: StackRepository, protected storageRepository: StorageRepository, protected syncRepository: SyncRepository, diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts index 4a747d41a3..c200897719 100644 --- a/server/src/services/notification-admin.service.spec.ts +++ b/server/src/services/notification-admin.service.spec.ts @@ -14,6 +14,7 @@ const smtpTransport = Object.freeze({ ignoreCert: false, host: 'localhost', port: 587, + secure: false, username: 'test', password: 'test', }, diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index a96caf5ac2..403ed44631 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -40,6 +40,7 @@ const configs = { ignoreCert: false, host: 'localhost', port: 587, + secure: false, username: 'test', password: 'test', }, diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 5d192523b1..af0c1b981e 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -162,7 +162,11 @@ export class NotificationService extends BaseService { const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]); if (asset) { - this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset)); + this.eventRepository.clientSend( + 'on_asset_update', + userId, + mapAsset(asset, { auth: { user: { id: userId } } as AuthDto }), + ); } } diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 9483cdddff..062214b975 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -300,6 +300,7 @@ describe(SharedLinkService.name, () => { mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLinkAsset.remove.mockResolvedValue([assetStub.image.id]); await expect( sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), @@ -308,6 +309,7 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); + expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith('link-1', [assetStub.image.id, 'asset-2']); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 096739d056..3c1a6083e9 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -175,10 +175,12 @@ export class SharedLinkService extends BaseService { throw new BadRequestException('Invalid shared link type'); } + const removedAssetIds = await this.sharedLinkAssetRepository.remove(id, dto.assetIds); + const results: AssetIdsResponseDto[] = []; for (const assetId of dto.assetIds) { - const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId); - if (!hasAsset) { + const wasRemoved = removedAssetIds.find((id) => id === assetId); + if (!wasRemoved) { results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND }); continue; } diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 50dffd5465..71cf0d0ce8 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -115,7 +115,7 @@ export class StorageService extends BaseService { if (!path.startsWith(previous)) { throw new Error( - 'Detected an inconsistent media location. For more information, see https://immich.app/errors#inconsistent-media-location', + 'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location', ); } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 5a9c7f4df3..c2dff1aaed 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -197,6 +197,7 @@ const updatedConfig = Object.freeze({ transport: { host: '', port: 587, + secure: false, username: '', password: '', ignoreCert: false, diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 11df30a7d4..3301e61318 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -36,10 +36,14 @@ describe(TimelineService.name, () => { ); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - timeBucket: 'bucket', - albumId: 'album-id', - }); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + { + timeBucket: 'bucket', + albumId: 'album-id', + }, + authStub.admin, + ); }); it('should return the assets for a archive time bucket if user has archive.read', async () => { @@ -60,6 +64,7 @@ describe(TimelineService.name, () => { visibility: AssetVisibility.Archive, userIds: [authStub.admin.user.id], }), + authStub.admin, ); }); @@ -76,12 +81,16 @@ describe(TimelineService.name, () => { withPartners: true, }), ).resolves.toEqual(json); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - timeBucket: 'bucket', - visibility: AssetVisibility.Timeline, - withPartners: true, - userIds: [authStub.admin.user.id], - }); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + { + timeBucket: 'bucket', + visibility: AssetVisibility.Timeline, + withPartners: true, + userIds: [authStub.admin.user.id], + }, + authStub.admin, + ); }); it('should check permissions to read tag', async () => { @@ -96,11 +105,15 @@ describe(TimelineService.name, () => { tagId: 'tag-123', }), ).resolves.toEqual(json); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - tagId: 'tag-123', - timeBucket: 'bucket', - userIds: [authStub.admin.user.id], - }); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + { + tagId: 'tag-123', + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }, + authStub.admin, + ); }); it('should return the assets for a library time bucket if user has library.read', async () => { @@ -119,6 +132,7 @@ describe(TimelineService.name, () => { timeBucket: 'bucket', userIds: [authStub.admin.user.id], }), + authStub.admin, ); }); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index d8cac3a205..b840883fa9 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -21,7 +21,7 @@ export class TimelineService extends BaseService { const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto }); // TODO: use id cursor for pagination - const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); + const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions, auth); return bucket.assets; } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f802e3113e..3f021f3eb7 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -33,6 +33,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; @@ -311,6 +312,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case SearchRepository: case SessionRepository: case SharedLinkRepository: + case SharedLinkAssetRepository: case StackRepository: case SyncRepository: case SyncCheckpointRepository: diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts index 88e7e86df5..acc51374d1 100644 --- a/server/test/medium/specs/services/shared-link.service.spec.ts +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -4,6 +4,7 @@ import { SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { DB } from 'src/schema'; @@ -17,7 +18,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(SharedLinkService, { database: db || defaultDatabase, - real: [AccessRepository, DatabaseRepository, SharedLinkRepository], + real: [AccessRepository, DatabaseRepository, SharedLinkRepository, SharedLinkAssetRepository], mock: [LoggingRepository, StorageRepository], }); }; @@ -62,4 +63,65 @@ describe(SharedLinkService.name, () => { }); }); }); + + it('should share individually assets', async () => { + const { sut, ctx } = setup(); + + const { user } = await ctx.newUser(); + + const assets = await Promise.all([ + ctx.newAsset({ ownerId: user.id }), + ctx.newAsset({ ownerId: user.id }), + ctx.newAsset({ ownerId: user.id }), + ]); + + for (const { asset } of assets) { + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + } + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: assets.map(({ asset }) => asset.id), + }); + + await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({ + assets: assets.map(({ asset }) => expect.objectContaining({ id: asset.id })), + }); + }); + + it('should remove individually shared 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.newExif({ assetId: asset.id, make: 'Canon' }); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + + await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({ + assets: [expect.objectContaining({ id: asset.id })], + }); + + await sut.removeAssets(auth, sharedLink.id, { + assetIds: [asset.id], + }); + + await expect(sut.getMine({ user, sharedLink }, {})).resolves.toHaveProperty('assets', []); + }); }); diff --git a/server/test/medium/specs/services/timeline.service.spec.ts b/server/test/medium/specs/services/timeline.service.spec.ts index fa4a75e869..eaca4dcc14 100644 --- a/server/test/medium/specs/services/timeline.service.spec.ts +++ b/server/test/medium/specs/services/timeline.service.spec.ts @@ -4,6 +4,7 @@ import { AssetVisibility } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; import { DB } from 'src/schema'; import { TimelineService } from 'src/services/timeline.service'; import { newMediumService } from 'test/medium.factory'; @@ -15,7 +16,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(TimelineService, { database: db || defaultDatabase, - real: [AssetRepository, AccessRepository], + real: [AssetRepository, AccessRepository, PartnerRepository], mock: [LoggingRepository], }); }; @@ -155,5 +156,54 @@ describe(TimelineService.name, () => { const response = JSON.parse(rawResponse); expect(response).toEqual(expect.objectContaining({ isTrashed: [true] })); }); + + it('should return false for favorite status unless asset owner', async () => { + const { sut, ctx } = setup(); + const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([ + ctx.newUser().then(async ({ user }) => { + const result = await ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('1970-02-12'), + localDateTime: new Date('1970-02-12'), + isFavorite: true, + }); + await ctx.newExif({ assetId: result.asset.id, make: 'Canon' }); + return result; + }), + ctx.newUser().then(async ({ user }) => { + const result = await ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('1970-02-13'), + localDateTime: new Date('1970-02-13'), + isFavorite: true, + }); + await ctx.newExif({ assetId: result.asset.id, make: 'Canon' }); + return result; + }), + ]); + + await Promise.all([ + ctx.newPartner({ sharedById: asset1.ownerId, sharedWithId: asset2.ownerId }), + ctx.newPartner({ sharedById: asset2.ownerId, sharedWithId: asset1.ownerId }), + ]); + + const auth1 = factory.auth({ user: { id: asset1.ownerId } }); + const rawResponse1 = await sut.getTimeBucket(auth1, { + timeBucket: '1970-02-01', + withPartners: true, + visibility: AssetVisibility.Timeline, + }); + const response1 = JSON.parse(rawResponse1); + expect(response1).toEqual(expect.objectContaining({ id: [asset2.id, asset1.id], isFavorite: [false, true] })); + + const auth2 = factory.auth({ user: { id: asset2.ownerId } }); + const rawResponse2 = await sut.getTimeBucket(auth2, { + timeBucket: '1970-02-01', + withPartners: true, + visibility: AssetVisibility.Timeline, + }); + const response2 = JSON.parse(rawResponse2); + expect(response2).toEqual(expect.objectContaining({ id: [asset2.id, asset1.id], isFavorite: [true, false] })); + }); }); }); diff --git a/server/test/utils.ts b/server/test/utils.ts index c23341d64c..bae9163b80 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -47,6 +47,7 @@ import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; @@ -236,6 +237,7 @@ export type ServiceOverrides = { serverInfo: ServerInfoRepository; session: SessionRepository; sharedLink: SharedLinkRepository; + sharedLinkAsset: SharedLinkAssetRepository; stack: StackRepository; storage: StorageRepository; sync: SyncRepository; @@ -307,6 +309,7 @@ export const newTestService = ( serverInfo: automock(ServerInfoRepository, { args: [, loggerMock], strict: false }), session: automock(SessionRepository), sharedLink: automock(SharedLinkRepository), + sharedLinkAsset: automock(SharedLinkAssetRepository), stack: automock(StackRepository), storage: newStorageRepositoryMock(), sync: automock(SyncRepository), @@ -357,6 +360,7 @@ export const newTestService = ( overrides.serverInfo || (mocks.serverInfo as As), overrides.session || (mocks.session as As), overrides.sharedLink || (mocks.sharedLink as As), + overrides.sharedLinkAsset || (mocks.sharedLinkAsset as As), overrides.stack || (mocks.stack as As), overrides.storage || (mocks.storage as As), overrides.sync || (mocks.sync as As), diff --git a/web/package.json b/web/package.json index 45cbdcc451..5eacb72b85 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ "lint:fix": "npm run lint -- --fix", "format": "prettier --check .", "format:fix": "prettier --write . && npm run format:i18n", - "format:i18n": "pnpx sort-json ../i18n/*.json", + "format:i18n": "pnpm dlx sort-json ../i18n/*.json", "test": "vitest --run", "test:cov": "vitest --coverage", "test:watch": "vitest dev", diff --git a/web/src/lib/components/admin-settings/NotificationSettings.svelte b/web/src/lib/components/admin-settings/NotificationSettings.svelte index d1d698d4fa..65ca6f9bd7 100644 --- a/web/src/lib/components/admin-settings/NotificationSettings.svelte +++ b/web/src/lib/components/admin-settings/NotificationSettings.svelte @@ -45,6 +45,7 @@ transport: { host: config.notifications.smtp.transport.host, port: config.notifications.smtp.transport.port, + secure: config.notifications.smtp.transport.secure, username: config.notifications.smtp.transport.username, password: config.notifications.smtp.transport.password, ignoreCert: config.notifications.smtp.transport.ignoreCert, @@ -128,6 +129,13 @@ savedConfig.notifications.smtp.transport.password} /> + + (showAssetPath = !showAssetPath); - let isShowChangeDate = $state(false); - - async function handleConfirmChangeDate(result: AbsoluteResult | RelativeResult) { - isShowChangeDate = false; - try { - if (result.mode === 'absolute') { - await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: result.date } }); - } - } catch (error) { - handleError(error, $t('errors.unable_to_change_date')); + const handleChangeDate = async () => { + if (!isOwner) { + return; } - } + + await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime }); + };
@@ -280,7 +271,7 @@ + + +

{$t('mobile_app_download_onboarding_note')}

diff --git a/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte b/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte index fc9853ca7d..c44839ae38 100644 --- a/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte +++ b/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte @@ -29,7 +29,7 @@
{zeros()}{value} {#if unit} - {unit} + {unit} {/if}
diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte deleted file mode 100644 index 930bafd599..0000000000 --- a/web/src/lib/components/shared-components/change-date.svelte +++ /dev/null @@ -1,284 +0,0 @@ - - - (confirmed ? handleConfirm() : onCancel())} -> - {#snippet promptSnippet()} - {#if withDuration} -
- - - -
- {/if} -
-
-
- - -
-
-
- - -
-
- {#if timezoneInput} -
- handleOnSelect(option)} - /> -
- {/if} -
- {$t('edit_date_and_time_by_offset_interval', { values: { from: intervalFrom, to: intervalTo } })} -
-
-
- {/snippet} -
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 725b456912..955ca64565 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -23,7 +23,7 @@ import { focusOutside } from '$lib/actions/focus-outside'; import { shortcuts } from '$lib/actions/shortcut'; import { generateId } from '$lib/utils/generate-id'; - import { Icon, IconButton } from '@immich/ui'; + import { Icon, IconButton, Label } from '@immich/ui'; import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js'; import { onMount, tick } from 'svelte'; import { t } from 'svelte-i18n'; @@ -251,7 +251,7 @@ - +
- import ChangeDate, { - type AbsoluteResult, - type RelativeResult, - } from '$lib/components/shared-components/change-date.svelte'; + import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; - import { user } from '$lib/stores/user.store'; - import { getSelectedAssets } from '$lib/utils/asset-utils'; - import { handleError } from '$lib/utils/handle-error'; - import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js'; - import { updateAssets } from '@immich/sdk'; + import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte'; + import { modalManager } from '@immich/ui'; import { mdiCalendarEditOutline } from '@mdi/js'; - import { DateTime, Duration } from 'luxon'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; - import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; interface Props { menuItem?: boolean; } @@ -20,66 +13,17 @@ let { menuItem = false }: Props = $props(); const { clearSelect, getOwnedAssets } = getAssetControlContext(); - let isShowChangeDate = $state(false); - - let currentInterval = $derived.by(() => { - if (isShowChangeDate) { - const ids = getSelectedAssets(getOwnedAssets(), $user); - const assets = getOwnedAssets().filter((asset) => ids.includes(asset.id)); - const imageTimestamps = assets.map((asset) => { - let localDateTime = fromTimelinePlainDateTime(asset.localDateTime); - let fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt); - let offsetMinutes = localDateTime.diff(fileCreatedAt, 'minutes').shiftTo('minutes').minutes; - const timeZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`; - return fileCreatedAt.setZone('utc', { keepLocalTime: true }).setZone(timeZone); - }); - let minTimestamp = imageTimestamps[0]; - let maxTimestamp = imageTimestamps[0]; - for (let current of imageTimestamps) { - if (current < minTimestamp) { - minTimestamp = current; - } - - if (current > maxTimestamp) { - maxTimestamp = current; - } - } - return { start: minTimestamp, end: maxTimestamp }; + const handleChangeDate = async () => { + const success = await modalManager.show(AssetSelectionChangeDateModal, { + initialDate: DateTime.now(), + assets: getOwnedAssets(), + }); + if (success) { + clearSelect(); } - return undefined; - }); - - const handleConfirm = async (result: AbsoluteResult | RelativeResult) => { - isShowChangeDate = false; - const ids = getSelectedAssets(getOwnedAssets(), $user); - - try { - if (result.mode === 'absolute') { - await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: result.date } }); - } else if (result.mode === 'relative') { - await updateAssets({ - assetBulkUpdateDto: { - ids, - dateTimeRelative: result.duration, - timeZone: result.timeZone, - }, - }); - } - } catch (error) { - handleError(error, $t('errors.unable_to_change_date')); - } - clearSelect(); }; {#if menuItem} - (isShowChangeDate = true)} /> -{/if} -{#if isShowChangeDate} - (isShowChangeDate = false)} - /> + {/if} diff --git a/web/src/lib/components/timeline/actions/ChangeDescriptionAction.svelte b/web/src/lib/components/timeline/actions/ChangeDescriptionAction.svelte index cf16d01172..6a4da927c3 100644 --- a/web/src/lib/components/timeline/actions/ChangeDescriptionAction.svelte +++ b/web/src/lib/components/timeline/actions/ChangeDescriptionAction.svelte @@ -2,7 +2,7 @@ import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte'; import { user } from '$lib/stores/user.store'; - import { getSelectedAssets } from '$lib/utils/asset-utils'; + import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { updateAssets } from '@immich/sdk'; import { modalManager } from '@immich/ui'; @@ -20,7 +20,7 @@ const handleUpdateDescription = async () => { const description = await modalManager.show(AssetUpdateDescriptionConfirmModal); if (description) { - const ids = getSelectedAssets(getOwnedAssets(), $user); + const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user); try { await updateAssets({ assetBulkUpdateDto: { ids, description } }); diff --git a/web/src/lib/components/timeline/actions/ChangeLocationAction.svelte b/web/src/lib/components/timeline/actions/ChangeLocationAction.svelte index 2ccfe3704d..bfb10bb382 100644 --- a/web/src/lib/components/timeline/actions/ChangeLocationAction.svelte +++ b/web/src/lib/components/timeline/actions/ChangeLocationAction.svelte @@ -2,7 +2,7 @@ import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { user } from '$lib/stores/user.store'; - import { getSelectedAssets } from '$lib/utils/asset-utils'; + import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { updateAssets } from '@immich/sdk'; import { mdiMapMarkerMultipleOutline } from '@mdi/js'; @@ -25,7 +25,7 @@ return; } - const ids = getSelectedAssets(getOwnedAssets(), $user); + const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user); try { await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } }); diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte index 6d7a64d3d8..d3f151a974 100644 --- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte +++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte @@ -2,10 +2,6 @@ import { goto } from '$app/navigation'; import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte'; - import ChangeDate, { - type AbsoluteResult, - type RelativeResult, - } from '$lib/components/shared-components/change-date.svelte'; import { setFocusToAsset as setFocusAssetInit, setFocusTo as setFocusToInit, @@ -13,6 +9,7 @@ import { AppRoute } from '$lib/constants'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -24,8 +21,6 @@ import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { AssetVisibility } from '@immich/sdk'; import { modalManager } from '@immich/ui'; - import { DateTime } from 'luxon'; - let { isViewing: showAssetViewer } = assetViewingStore; interface Props { timelineManager: TimelineManager; @@ -43,7 +38,7 @@ scrollToAsset, }: Props = $props(); - let isShowSelectDate = $state(false); + const { isViewing: showAssetViewer } = assetViewingStore; const trashOrDelete = async (force: boolean = false) => { isShowDeleteConfirmation = false; @@ -150,6 +145,13 @@ const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager); const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset); + const handleOpenDateModal = async () => { + const asset = await modalManager.show(NavigateToDateModal, { timelineManager }); + if (asset) { + setFocusAsset(asset); + } + }; + let shortcutList = $derived( (() => { if (searchStore.isSearchEnabled || $showAssetViewer) { @@ -168,7 +170,7 @@ { shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') }, { shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') }, { shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') }, - { shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) }, + { shortcut: { key: 'G' }, onShortcut: handleOpenDateModal }, ]; if (onEscape) { shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape }); @@ -198,24 +200,3 @@ onConfirm={() => handlePromiseError(trashOrDelete(true))} /> {/if} - -{#if isShowSelectDate} - { - isShowSelectDate = false; - if (dateString.mode == 'absolute') { - const asset = await timelineManager.getClosestAssetToDate( - (DateTime.fromISO(dateString.date) as DateTime).toObject(), - ); - if (asset) { - setFocusAsset(asset); - } - } - }} - onCancel={() => (isShowSelectDate = false)} - /> -{/if} diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index fc747dc6af..bf7090e310 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,7 +1,15 @@ + + + +
+
+ + + Get it on F-Droid + +
+ +
+ + + Get it on Google Play + +
+ +
+ + + Download on the App Store + +
+
+
+
diff --git a/web/src/lib/modals/AssetChangeDateModal.svelte b/web/src/lib/modals/AssetChangeDateModal.svelte new file mode 100644 index 0000000000..7034493924 --- /dev/null +++ b/web/src/lib/modals/AssetChangeDateModal.svelte @@ -0,0 +1,73 @@ + + + onClose(false)} size="small"> + + + + {#if timezoneInput} +
+ +
+ {/if} +
+ + + + + + +
diff --git a/web/src/lib/components/shared-components/change-date.spec.ts b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts similarity index 61% rename from web/src/lib/components/shared-components/change-date.spec.ts rename to web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts index 63926a44a6..ab7f24db25 100644 --- a/web/src/lib/components/shared-components/change-date.spec.ts +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts @@ -1,33 +1,30 @@ +import { getAnimateMock } from '$lib/__mocks__/animate.mock'; import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; +import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock'; +import { calcNewDate } from '$lib/modals/timezone-utils'; import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { DateTime } from 'luxon'; -import ChangeDate from './change-date.svelte'; +import AssetSelectionChangeDateModal from './AssetSelectionChangeDateModal.svelte'; -describe('ChangeDate component', () => { +describe('DateSelectionModal component', () => { const initialDate = DateTime.fromISO('2024-01-01'); const initialTimeZone = 'Europe/Berlin'; - const currentInterval = { - start: DateTime.fromISO('2000-02-01T14:00:00+01:00'), - end: DateTime.fromISO('2001-02-01T14:00:00+01:00'), - }; - const onCancel = vi.fn(); - const onConfirm = vi.fn(); + + const onClose = vi.fn(); const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch'); const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement; const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement; - const getCancelButton = () => screen.getByText('Cancel'); - const getConfirmButton = () => screen.getByText('Confirm'); + const getCancelButton = () => screen.getByText('cancel'); + const getConfirmButton = () => screen.getByText('confirm'); beforeEach(() => { vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); vi.stubGlobal('visualViewport', getVisualViewportMock()); - }); - - afterEach(() => { vi.resetAllMocks(); + Element.prototype.animate = getAnimateMock(); }); afterAll(async () => { @@ -38,54 +35,75 @@ describe('ChangeDate component', () => { }); test('should render correct values', () => { - render(ChangeDate, { initialDate, initialTimeZone, onCancel, onConfirm }); + render(AssetSelectionChangeDateModal, { + initialDate, + initialTimeZone, + assets: [], + + onClose, + }); expect(getDateInput().value).toBe('2024-01-01T00:00'); expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)'); }); test('calls onConfirm with correct date on confirm', async () => { - render(ChangeDate, { - props: { initialDate, initialTimeZone, onCancel, onConfirm }, + render(AssetSelectionChangeDateModal, { + props: { initialDate, initialTimeZone, assets: [], onClose }, }); await fireEvent.click(getConfirmButton()); - expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' }); + expect(sdkMock.updateAssets).toHaveBeenCalledWith({ + assetBulkUpdateDto: { + ids: [], + dateTimeOriginal: '2024-01-01T00:00:00.000+01:00', + }, + }); }); test('calls onCancel on cancel', async () => { - render(ChangeDate, { - props: { initialDate, initialTimeZone, onCancel, onConfirm }, + render(AssetSelectionChangeDateModal, { + props: { initialDate, initialTimeZone, assets: [], onClose }, }); await fireEvent.click(getCancelButton()); - expect(onCancel).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); }); describe('when date is in daylight saving time', () => { const dstDate = DateTime.fromISO('2024-07-01'); test('should render correct timezone with offset', () => { - render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }); + render(AssetSelectionChangeDateModal, { + initialDate: dstDate, + initialTimeZone, + assets: [], + onClose, + }); expect(getTimeZoneInput().value).toBe('Europe/Berlin (+02:00)'); }); test('calls onConfirm with correct date on confirm', async () => { - render(ChangeDate, { - props: { initialDate: dstDate, initialTimeZone, onCancel, onConfirm }, + render(AssetSelectionChangeDateModal, { + props: { initialDate: dstDate, initialTimeZone, assets: [], onClose }, }); await fireEvent.click(getConfirmButton()); - expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' }); + expect(sdkMock.updateAssets).toHaveBeenCalledWith({ + assetBulkUpdateDto: { + ids: [], + dateTimeOriginal: '2024-07-01T00:00:00.000+02:00', + }, + }); }); }); test('calls onConfirm with correct offset in relative mode', async () => { - render(ChangeDate, { - props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, + render(AssetSelectionChangeDateModal, { + props: { initialDate, initialTimeZone, assets: [], onClose }, }); await fireEvent.click(getRelativeInputToggle()); @@ -104,17 +122,19 @@ describe('ChangeDate component', () => { await fireEvent.click(getConfirmButton()); - expect(onConfirm).toHaveBeenCalledWith({ - mode: 'relative', - duration: days * 60 * 24 + hours * 60 + minutes, - timeZone: undefined, + expect(sdkMock.updateAssets).toHaveBeenCalledWith({ + assetBulkUpdateDto: { + ids: [], + dateTimeRelative: days * 60 * 24 + hours * 60 + minutes, + timeZone: 'Europe/Berlin', + }, }); }); test('calls onConfirm with correct timeZone in relative mode', async () => { const user = userEvent.setup(); - render(ChangeDate, { - props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, + render(AssetSelectionChangeDateModal, { + props: { initialDate, initialTimeZone, assets: [], onClose }, }); await user.click(getRelativeInputToggle()); @@ -123,10 +143,13 @@ describe('ChangeDate component', () => { await user.keyboard('{Enter}'); await user.click(getConfirmButton()); - expect(onConfirm).toHaveBeenCalledWith({ - mode: 'relative', - duration: 0, - timeZone: initialTimeZone, + + expect(sdkMock.updateAssets).toHaveBeenCalledWith({ + assetBulkUpdateDto: { + ids: [], + dateTimeRelative: 0, + timeZone: 'Europe/Berlin', + }, }); }); @@ -136,55 +159,50 @@ describe('ChangeDate component', () => { timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }), duration: 0, timezone: undefined, - expectedResult: 'Jan 1, 2024, 12:00 AM GMT+01:00', + expectedResult: '2024-01-01T00:00:00.000', }, { timestamp: DateTime.fromISO('2024-01-01T04:00:00.000+05:00', { setZone: true }), duration: 0, timezone: undefined, - expectedResult: 'Jan 1, 2024, 4:00 AM GMT+05:00', + expectedResult: '2024-01-01T04:00:00.000', }, { timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+00:00', { setZone: true }), duration: 0, timezone: 'Europe/Berlin', - expectedResult: 'Jan 1, 2024, 1:00 AM GMT+01:00', + expectedResult: '2024-01-01T01:00:00.000', }, { timestamp: DateTime.fromISO('2024-07-01T00:00:00.000+00:00', { setZone: true }), duration: 0, timezone: 'Europe/Berlin', - expectedResult: 'Jul 1, 2024, 2:00 AM GMT+02:00', + expectedResult: '2024-07-01T02:00:00.000', }, { timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }), duration: 1440, timezone: undefined, - expectedResult: 'Jan 2, 2024, 12:00 AM GMT+01:00', + expectedResult: '2024-01-02T00:00:00.000', }, { timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }), duration: -1440, timezone: undefined, - expectedResult: 'Dec 31, 2023, 12:00 AM GMT+01:00', + expectedResult: '2023-12-31T00:00:00.000', }, { timestamp: DateTime.fromISO('2024-01-01T00:00:00.000-01:00', { setZone: true }), duration: -1440, timezone: 'America/Anchorage', - expectedResult: 'Dec 30, 2023, 4:00 PM GMT-09:00', + expectedResult: '2023-12-30T16:00:00.000', }, ]; - const component = render(ChangeDate, { - props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm }, - }); - for (const testCase of testCases) { - expect( - component.component.calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), - JSON.stringify(testCase), - ).toBe(testCase.expectedResult); + expect(calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone), JSON.stringify(testCase)).toBe( + testCase.expectedResult, + ); } }); }); diff --git a/web/src/lib/modals/AssetSelectionChangeDateModal.svelte b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte new file mode 100644 index 0000000000..8eb1a481cc --- /dev/null +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.svelte @@ -0,0 +1,128 @@ + + + onClose(false)} size="small"> + + + + + {#if showRelative} + + + {:else} + + + {/if} +
+ (lastSelectedTimezone = option as ZoneOption)} + > +
+ +
+ + + + + + +
diff --git a/web/src/lib/modals/NavigateToDateModal.svelte b/web/src/lib/modals/NavigateToDateModal.svelte new file mode 100644 index 0000000000..4b83c66bc6 --- /dev/null +++ b/web/src/lib/modals/NavigateToDateModal.svelte @@ -0,0 +1,61 @@ + + + onClose()}> + + + + + + + + + + + + + + + + + diff --git a/web/src/lib/modals/ObtainiumConfigModal.svelte b/web/src/lib/modals/ObtainiumConfigModal.svelte new file mode 100644 index 0000000000..d7132f2f85 --- /dev/null +++ b/web/src/lib/modals/ObtainiumConfigModal.svelte @@ -0,0 +1,94 @@ + + + + +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+ {#if inputUrl && inputApiKey && archVariant} + + Get it on Obtainium + + {:else} + + {/if} +
+
+
+
diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index 9bd7b29b94..ebb5ea3c60 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -27,6 +27,7 @@ { key: ['D', 'd'], action: $t('previous_or_next_day') }, { key: ['M', 'm'], action: $t('previous_or_next_month') }, { key: ['Y', 'y'], action: $t('previous_or_next_year') }, + { key: ['g'], action: $t('navigate_to_time') }, { key: ['x'], action: $t('select') }, { key: ['Esc'], action: $t('back_close_deselect') }, { key: ['Ctrl', 'k'], action: $t('search_your_photos') }, diff --git a/web/src/lib/modals/timezone-utils.ts b/web/src/lib/modals/timezone-utils.ts new file mode 100644 index 0000000000..c7bb00fd69 --- /dev/null +++ b/web/src/lib/modals/timezone-utils.ts @@ -0,0 +1,149 @@ +import { DateTime, Duration } from 'luxon'; + +export type ZoneOption = { + /** + * Timezone name with offset + * + * e.g. Asia/Jerusalem (+03:00) + */ + label: string; + + /** + * Timezone name + * + * e.g. Asia/Jerusalem + */ + value: string; + + /** + * Timezone offset in minutes + * + * e.g. 300 + */ + offsetMinutes: number; + + /** + * True iff the date is valid + * + * Dates may be invalid for various reasons, for example setting a day that does not exist (30 Feb 2024). + * Due to daylight saving time, 2:30am is invalid for Europe/Berlin on Mar 31 2024.The two following local times + * are one second apart: + * + * - Mar 31 2024 01:59:59 (GMT+0100, unix timestamp 1725058799) + * - Mar 31 2024 03:00:00 (GMT+0200, unix timestamp 1711846800) + * + * Mar 31 2024 02:30:00 does not exist in Europe/Berlin, this is an invalid date/time/time zone combination. + */ + valid: boolean; +}; + +const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; +const knownTimezones = Intl.supportedValuesOf('timeZone'); + +export function getTimezones(selectedDate: string) { + // Use a fixed modern date to calculate stable timezone offsets for the list + // This ensures that the offsets shown in the combobox are always current, + // regardless of the historical date selected by the user. + return knownTimezones + .map((zone) => zoneOptionForDate(zone, selectedDate)) + .filter((zone) => zone.valid) + .sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB)); +} + +export function getModernOffsetForZoneAndDate( + zone: string, + dateString: string, +): { offsetMinutes: number; offsetFormat: string } { + const dt = DateTime.fromISO(dateString, { zone }); + + // we determine the *modern* offset for this zone based on its current rules. + // To do this, we "move" the date to the current year, keeping the local time components. + // This allows Luxon to apply current-year DST rules. + const modernYearDt = dt.set({ year: DateTime.now().year }); + + // Calculate the offset at that modern year's date. + const modernOffsetMinutes = modernYearDt.setZone(zone, { keepLocalTime: true }).offset; + const modernOffsetFormat = modernYearDt.setZone(zone, { keepLocalTime: true }).toFormat('ZZ'); + + return { offsetMinutes: modernOffsetMinutes, offsetFormat: modernOffsetFormat }; +} + +function zoneOptionForDate(zone: string, date: string) { + const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date); + // For validity, we still need to check if the exact date/time exists in the *original* timezone (for gaps/overlaps). + const dateForValidity = DateTime.fromISO(date, { zone }); + const valid = dateForValidity.isValid && date === dateForValidity.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + return { + value: zone, + offsetMinutes, + label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'), + valid, + }; +} + +function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) { + const offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes; + if (offsetDifference != 0) { + return offsetDifference; + } + return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' }); +} + +/* + * If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00). + * + * This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)" + * instead of just the raw offset or something like "UTC+02:00". + * + * The provided information (initialDate, from some asset) includes the offset (e.g. +02:00), but no information about + * the actual time zone. As several countries/regions may share the same offset, for example Berlin (Germany) and + * Blantyre (Malawi) sharing +02:00 in summer, we have to guess and somehow pick a suitable time zone. + * + * If the time zone configured by the user (in the browser) provides the same offset for the given date (accounting + * for daylight saving time and other weirdness), we prefer to show it. This way, for German users, we might be able + * to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre". + */ +export function getPreferredTimeZone( + date: DateTime, + initialTimeZone: string | undefined, + timezones: ZoneOption[], + selectedOption?: ZoneOption, +) { + const offset = date.offset; + const previousSelection = timezones.find((item) => item.value === selectedOption?.value); + const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone); + const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone); + const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset); + const utcFallback = { + label: 'UTC (+00:00)', + offsetMinutes: 0, + value: 'UTC', + valid: true, + }; + return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback; +} + +export function toDatetime(selectedDate: string, selectedZone: ZoneOption) { + const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' }); + + // Determine the modern, DST-aware offset for the selected IANA zone + const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedZone.value, selectedDate); + + // Construct the final ISO string with a fixed-offset zone. + const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`; + + // Create a DateTime object in this fixed-offset zone, preserving the local time. + return DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone }); +} + +export function toIsoDate(selectedDate: string, selectedZone: ZoneOption) { + return toDatetime(selectedDate, selectedZone).toISO({ includeOffset: true })!; +} + +export const calcNewDate = (timestamp: DateTime, selectedDuration: number, timezone?: string) => { + let newDateTime = timestamp.plus({ minutes: selectedDuration }); + if (timezone) { + newDateTime = newDateTime.setZone(timezone); + } + return newDateTime.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); +}; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 25e045c8a1..5211f0bf72 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -405,7 +405,7 @@ export const getAssetType = (type: AssetTypeEnum) => { } }; -export const getSelectedAssets = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => { +export const getOwnedAssetsWithWarning = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => { const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id); const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 673b929a1c..60811c24f0 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -154,12 +154,6 @@ export function formatGroupTitle(_date: DateTime): string { export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); -export const getDateTimeOffsetLocaleString = (date: DateTime, opts?: LocaleOptions): string => - date.toLocaleString( - { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'longOffset' }, - opts, - ); - export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => { if (isTimelineAsset(unknownAsset)) { return unknownAsset; diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 44f3792a5b..c046c5fadf 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -5,6 +5,7 @@ import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte'; import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte'; import OnboardingLocale from '$lib/components/onboarding-page/onboarding-language.svelte'; + import OnboardingMobileApp from '$lib/components/onboarding-page/onboarding-mobile-app.svelte'; import OnboardingServerPrivacy from '$lib/components/onboarding-page/onboarding-server-privacy.svelte'; import OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte'; import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; @@ -14,7 +15,14 @@ import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk'; - import { mdiCloudCheckOutline, mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js'; + import { + mdiCellphoneArrowDownVariant, + mdiCloudCheckOutline, + mdiHarddisk, + mdiIncognito, + mdiThemeLightDark, + mdiTranslate, + } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -26,6 +34,7 @@ | typeof OnboardingStorageTemplate | typeof OnboardingServerPrivacy | typeof OnboardingUserPrivacy + | typeof OnboardingMobileApp | typeof OnboardingLocale; role: OnboardingRole; title?: string; @@ -76,6 +85,13 @@ title: $t('admin.backup_onboarding_title'), icon: mdiCloudCheckOutline, }, + { + name: 'mobile_app', + component: OnboardingMobileApp, + role: OnboardingRole.USER, + title: $t('mobile_app'), + icon: mdiCellphoneArrowDownVariant, // or you can use mdiCellphone + }, ]); let index = $state(0);