Compare commits

...

16 commits

Author SHA1 Message Date
renovate[bot]
0fee7d2a79
chore(deps): update github-actions 2025-10-17 14:50:16 +00:00
renovate[bot]
73da80394e
chore(deps): update github-actions (#22914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 10:48:36 -04:00
renovate[bot]
471cc74ff2
chore(deps): update dependency happy-dom to v20.0.2 [security] (#22964)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 10:24:50 -04:00
Lee Peuker
ca745d00ee
fix(docs): cli upload json format example (#22858)
Fix cli upload json format example
2025-10-17 14:08:42 +00:00
Jason Rasmussen
3ea8d140a2
feat: move community projects and guides to immich-aweseome (#23016) 2025-10-17 10:00:28 -04:00
Jason Rasmussen
8b8012f89d
docs: clarify well-known usage (#23018) 2025-10-17 10:00:07 -04:00
Joren Guillaume
4b7f851428
docs: Expand on OpenVINO WSL HW accel (#21054)
* add group/render section to openvino-wsl hwaccel

* Fix indentation for YAML

* Remove obsolete enter

* chore: formatting

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-10-17 09:05:07 -04:00
Nicholas
cc1cd299f3
feat(web): Download links and Obtainium link generator on Utilities page and onboarding (#20589) 2025-10-17 13:22:00 +02:00
Paul Larsen
3163afd24a
fix(web): render context overlays over the scrollbar (#23007) 2025-10-17 12:35:19 +02:00
Clement Martin
95889a69c9
feat(server): Option to configure SMTPS transport (#22833)
* feat(server): Option to configure SMTPS transport

This commit adds a boolean option in the SMTP transport configuration to
enable the so-called "secure" mode. What it does is use SMTP over TLS
instead of relying on the more common STARTTLS option over plain SMTP.

* Add missing field in dto

* Add missing field

* Use a switch instead of text field

* Add field in spec

* chore: regen open-api

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-10-17 10:21:27 +00:00
Brandon Wees
81554e5ad1
chore: change usage of pnpx to pnpm dlx (#23009) 2025-10-17 12:20:50 +02:00
Paweł Wojtaszko
505e16c37c
fix(server): only asset owner should see favorite status (#20654)
Some checks are pending
Docker / pre-job (push) Waiting to run
Docker / Re-Tag ML (push) Blocked by required conditions
Docker / Re-Tag Server (push) Blocked by required conditions
Docker / Build and Push ML (push) Blocked by required conditions
Docker / Build and Push Server (push) Blocked by required conditions
Docker / Docker Build & Push Server Success (push) Blocked by required conditions
Docker / Docker Build & Push ML Success (push) Blocked by required conditions
Docs build / pre-job (push) Waiting to run
Docs build / Docs Build (push) Blocked by required conditions
Zizmor / Zizmor (push) Waiting to run
Static Code Analysis / pre-job (push) Waiting to run
Static Code Analysis / Run Dart Code Analysis (push) Blocked by required conditions
Test / pre-job (push) Waiting to run
Test / Test & Lint Server (push) Blocked by required conditions
Test / Unit Test CLI (push) Blocked by required conditions
Test / Unit Test CLI (Windows) (push) Blocked by required conditions
Test / Lint Web (push) Blocked by required conditions
Test / Test Web (push) Blocked by required conditions
Test / Test i18n (push) Blocked by required conditions
Test / End-to-End Lint (push) Blocked by required conditions
Test / Medium Tests (Server) (push) Blocked by required conditions
Test / End-to-End Tests (Server & CLI) (push) Blocked by required conditions
Test / End-to-End Tests (Web) (push) Blocked by required conditions
Test / End-to-End Tests Success (push) Blocked by required conditions
Test / Unit Test Mobile (push) Blocked by required conditions
Test / Unit Test ML (push) Blocked by required conditions
Test / .github Files Formatting (push) Blocked by required conditions
Test / ShellCheck (push) Waiting to run
Test / OpenAPI Clients (push) Waiting to run
Test / SQL Schema Checks (push) Waiting to run
* fix: Any asset update disables isFavorite action in GUI. Only owner of asset in album should see favorited image.

* Fix unit tests

* Fix formatting

* better query, add medium test

* update sql

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-10-16 21:52:36 +00:00
Jason Rasmussen
24bfdf3263
fix(web): immich-form-label usage (#23006) 2025-10-16 17:49:12 -04:00
Jorge Montejo
a23dfff6cf
fix: remove assets from shared link (#22935)
* fix remove assets from shared link

* rename var

* test: should remove individually shared asset

* test: should share individually assets

* fix failing tests
2025-10-16 15:03:41 -04:00
Min Idzelis
2919ee4c65
fix: navigate to time action (#20928)
* fix: navigate to time action

* change-date -> DateSelectionModal; use luxon; use handle* for callback fn name

* refactor change-date dialogs

* Review comments

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-10-16 17:44:09 +00:00
Alex
d0eae97037
fix: unit overlapses value in server stats card (#22994) 2025-10-16 17:14:39 +00:00
84 changed files with 1231 additions and 953 deletions

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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}}'

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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://<backend_url>:2283;
}
# useful when using Let's Encrypt http-01 challenge
# location = /.well-known/immich {
# proxy_pass http://<backend_url>: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://<backend_url>: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.

View file

@ -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';
<CommunityGuides />

View file

@ -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';
<CommunityProjects />

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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 repositorys 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 (
<section className="flex flex-col gap-4 justify-between dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl px-4 py-6">
<div className="flex flex-col gap-2">
<p className="m-0 items-start flex gap-2 text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">
<span>{title}</span>
</p>
<p className="m-0 text-sm text-gray-600 dark:text-gray-300">{description}</p>
<p className="m-0 text-sm text-gray-600 dark:text-gray-300 my-4">
<a href={url}>{url}</a>
</p>
</div>
<div className="flex">
<Link
className="px-4 py-2 bg-immich-primary/10 dark:bg-gray-300 rounded-xl text-sm hover:no-underline text-immich-primary dark:text-immich-dark-bg font-semibold"
to={url}
>
View Guide
</Link>
</div>
</section>
);
}
export default function CommunityGuides(): JSX.Element {
return (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{guides.map((guides) => (
<CommunityGuide {...guides} />
))}
</div>
);
}

View file

@ -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 (
<section className="flex flex-col gap-4 justify-between dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl px-4 py-6">
<div className="flex flex-col gap-2">
<p className="m-0 items-start flex gap-2 text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">
<span>{title}</span>
</p>
<p className="m-0 text-sm text-gray-600 dark:text-gray-300">{description}</p>
<p className="m-0 text-sm text-gray-600 dark:text-gray-300 my-4">
<a href={url}>{url}</a>
</p>
</div>
<div className="flex">
<Link
className="px-4 py-2 bg-immich-primary/10 dark:bg-gray-300 rounded-xl text-sm hover:no-underline text-immich-primary dark:text-immich-dark-bg font-semibold"
to={url}
>
View Link
</Link>
</div>
</section>
);
}
export default function CommunityProjects(): JSX.Element {
return (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{projects.map((project) => (
<CommunityProject {...project} />
))}
</div>
);
}

View file

@ -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';

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!,
port: num.parse('${json[r'port']}'),
secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!,
);
}
@ -127,6 +134,7 @@ class SystemConfigSmtpTransportDto {
'ignoreCert',
'password',
'port',
'secure',
'username',
};
}

View file

@ -15,7 +15,7 @@ function dart {
patch --no-backup-if-mismatch -u api.mustache <api.mustache.patch
cd ../../
pnpx @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
pnpm dlx @openapitools/openapi-generator-cli generate -g dart -i ./immich-openapi-specs.json -o ../mobile/openapi -t ./templates/mobile
# Post generate patches
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch
@ -27,7 +27,7 @@ function dart {
}
function typescript {
pnpx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm --filter @immich/sdk install --frozen-lockfile
pnpm --filter @immich/sdk build
}

View file

@ -16726,6 +16726,9 @@
"minimum": 0,
"type": "number"
},
"secure": {
"type": "boolean"
},
"username": {
"type": "string"
}
@ -16735,6 +16738,7 @@
"ignoreCert",
"password",
"port",
"secure",
"username"
],
"type": "object"

View file

@ -71,6 +71,7 @@ export type SystemConfigSmtpTransportDto = {
ignoreCert: boolean;
password: string;
port: number;
secure: boolean;
username: string;
};
export type SystemConfigSmtpDto = {

140
pnpm-lock.yaml generated
View file

@ -67,7 +67,7 @@ importers:
version: 22.18.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 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))
version: 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))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@ -115,10 +115,10 @@ importers:
version: 5.1.4(typescript@5.9.3)(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:
specifier: ^3.0.0
version: 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)
version: 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-fetch-mock:
specifier: ^0.4.0
version: 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))
version: 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))
yaml:
specifier: ^2.3.1
version: 2.8.1
@ -284,7 +284,7 @@ importers:
version: 5.2.1(encoding@0.1.13)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@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)
version: 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)
open-api/typescript-sdk:
dependencies:
@ -613,7 +613,7 @@ importers:
version: 13.15.3
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 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))
version: 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))
eslint:
specifier: ^9.14.0
version: 9.37.0(jiti@2.6.1)
@ -670,7 +670,7 @@ importers:
version: 5.1.4(typescript@5.9.3)(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:
specifier: ^3.0.0
version: 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)
version: 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)
web:
dependencies:
@ -733,7 +733,7 @@ importers:
version: 4.7.8
happy-dom:
specifier: ^20.0.0
version: 20.0.0
version: 20.0.2
intl-messageformat:
specifier: ^10.7.11
version: 10.7.17
@ -794,25 +794,25 @@ importers:
version: 3.1.2
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 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)))
version: 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)))
'@sveltejs/enhanced-img':
specifier: ^0.8.0
version: 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))
version: 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))
'@sveltejs/kit':
specifier: ^2.27.1
version: 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))
version: 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/vite-plugin-svelte':
specifier: 6.2.1
version: 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))
version: 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))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 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))
version: 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))
'@testing-library/jest-dom':
specifier: ^6.4.2
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 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))
version: 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))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.0)
@ -836,7 +836,7 @@ importers:
version: 1.5.5
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 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))
version: 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))
dotenv:
specifier: ^17.0.0
version: 17.2.3
@ -896,10 +896,10 @@ importers:
version: 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.1.2
version: 7.1.9(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.1)
version: 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:
specifier: ^3.0.0
version: 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)
version: 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)
packages:
@ -4592,14 +4592,14 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.2':
resolution: {integrity: sha512-9pLGGwdzOUBDYi0GNjM97FIA+f92fqSke6joWeBjWXllfNxZBs7qeMF7tvtOIsbY45xkWkxrdwUfUf3MnQa9gA==}
'@types/node@20.19.21':
resolution: {integrity: sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==}
'@types/node@22.18.10':
resolution: {integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==}
'@types/node@24.7.1':
resolution: {integrity: sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==}
'@types/node@24.7.2':
resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==}
'@types/nodemailer@7.0.2':
resolution: {integrity: sha512-Zo6uOA9157WRgBk/ZhMpTQ/iCWLMk7OIs/Q9jvHarMvrzUUP/MDdPHL2U1zpf57HrrWGv4nYQn5uIxna0xY3xw==}
@ -6976,8 +6976,8 @@ packages:
engines: {node: '>=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

View file

@ -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<SystemConfig>({
ignoreCert: false,
host: '',
port: 587,
secure: false,
username: '',
password: '',
},

View file

@ -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,

View file

@ -463,6 +463,9 @@ class SystemConfigSmtpTransportDto {
@Max(65_535)
port!: number;
@ValidateBoolean()
secure!: boolean;
@IsString()
username!: string;

View file

@ -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

View file

@ -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',

View file

@ -23,6 +23,7 @@ export type SendEmailOptions = {
export type SmtpOptions = {
host: string;
port?: number;
secure?: boolean;
username?: string;
password?: string;
ignoreCert?: boolean;

View file

@ -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,

View file

@ -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<DB>) {}
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);
}
}

View file

@ -16,7 +16,9 @@ export async function up(db: Kysely<any>): Promise<void> {
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;

View file

@ -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,

View file

@ -14,6 +14,7 @@ const smtpTransport = Object.freeze<SystemConfig>({
ignoreCert: false,
host: 'localhost',
port: 587,
secure: false,
username: 'test',
password: 'test',
},

View file

@ -40,6 +40,7 @@ const configs = {
ignoreCert: false,
host: 'localhost',
port: 587,
secure: false,
username: 'test',
password: 'test',
},

View file

@ -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 }),
);
}
}

View file

@ -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: [] });
});
});

View file

@ -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;
}

View file

@ -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',
);
}

View file

@ -197,6 +197,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
transport: {
host: '',
port: 587,
secure: false,
username: '',
password: '',
ignoreCert: false,

View file

@ -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,
);
});

View file

@ -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;
}

View file

@ -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 = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
case SharedLinkAssetRepository:
case StackRepository:
case SyncRepository:
case SyncCheckpointRepository:

View file

@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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', []);
});
});

View file

@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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] }));
});
});
});

View file

@ -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 = <T extends BaseService>(
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 = <T extends BaseService>(
overrides.serverInfo || (mocks.serverInfo as As<ServerInfoRepository>),
overrides.session || (mocks.session as As<SessionRepository>),
overrides.sharedLink || (mocks.sharedLink as As<SharedLinkRepository>),
overrides.sharedLinkAsset || (mocks.sharedLinkAsset as As<SharedLinkAssetRepository>),
overrides.stack || (mocks.stack as As<StackRepository>),
overrides.storage || (mocks.storage as As<StorageRepository>),
overrides.sync || (mocks.sync as As<SyncRepository>),

View file

@ -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",

View file

@ -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}
/>
<SettingSwitch
title={$t('admin.notification_email_secure')}
subtitle={$t('admin.notification_email_secure_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:checked={config.notifications.smtp.transport.secure}
/>
<SettingSwitch
title={$t('admin.notification_email_ignore_certificate_errors')}
subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}

View file

@ -5,12 +5,9 @@
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
@ -19,12 +16,11 @@
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAssetInfo, updateAsset, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import {
mdiCalendar,
mdiCameraIris,
@ -59,7 +55,7 @@
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone);
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
@ -112,18 +108,13 @@
const toggleAssetPath = () => (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 });
};
</script>
<section class="relative p-2">
@ -280,7 +271,7 @@
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
onclick={handleChangeDate}
title={isOwner ? $t('edit_date') : ''}
class:hover:text-primary={isOwner}
>
@ -336,16 +327,6 @@
</div>
{/if}
{#if isShowChangeDate}
<ChangeDate
initialDate={dateTime}
initialTimeZone={timeZone ?? ''}
withDuration={false}
onConfirm={handleConfirmChangeDate}
onCancel={() => (isShowChangeDate = false)}
/>
{/if}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Button, HStack, modalManager } from '@immich/ui';
import { mdiCellphoneArrowDownVariant, mdiLinkEdit } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<HStack wrap>
<Button
size="large"
shape="semi-round"
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
leadingIcon={mdiLinkEdit}
>
{$t('obtainium_configurator')}
</Button>
<Button
size="large"
shape="semi-round"
onclick={() => modalManager.show(AppDownloadModal, {})}
leadingIcon={mdiCellphoneArrowDownVariant}
>
{$t('app_download_links')}
</Button>
</HStack>
<p>{$t('mobile_app_download_onboarding_note')}</p>

View file

@ -29,7 +29,7 @@
<div class="relative mx-auto font-mono text-2xl font-semibold">
<span class="text-gray-400 dark:text-gray-600">{zeros()}</span><span>{value}</span>
{#if unit}
<Code color="muted" class="absolute -top-5 end-1 font-light">{unit}</Code>
<Code color="muted" class="absolute -top-5 end-1 font-light p-0">{unit}</Code>
{/if}
</div>
</div>

View file

@ -1,284 +0,0 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import DurationInput from '$lib/elements/DurationInput.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
import { ConfirmModal, Field, Switch } from '@immich/ui';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import Combobox, { type ComboBoxOption } from './combobox.svelte';
interface Props {
title?: string;
initialDate?: DateTime;
initialTimeZone?: string;
timezoneInput?: boolean;
withDuration?: boolean;
currentInterval?: { start: DateTime; end: DateTime };
onCancel: () => void;
onConfirm: (result: AbsoluteResult | RelativeResult) => void;
}
let {
initialDate = DateTime.now(),
initialTimeZone = '',
title = $t('edit_date_and_time'),
timezoneInput = true,
withDuration = true,
currentInterval = undefined,
onCancel,
onConfirm,
}: Props = $props();
export type AbsoluteResult = {
mode: 'absolute';
date: string;
};
export type RelativeResult = {
mode: 'relative';
duration?: number;
timeZone?: string;
};
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;
};
let showRelative = $state(false);
let selectedDuration = $state(0);
const knownTimezones = Intl.supportedValuesOf('timeZone');
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
// 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.
let timezones: ZoneOption[] = knownTimezones
.map((zone) => zoneOptionForDate(zone, selectedDate))
.filter((zone) => zone.valid)
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedAbsoluteOption: ZoneOption | undefined = $state(
getPreferredTimeZone(userTimeZone, timezones, initialDate),
);
let selectedRelativeOption: ZoneOption | undefined = $state(undefined);
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,
};
}
/*
* 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".
*/
function getPreferredTimeZone(
userTimeZone: string,
timezones: ZoneOption[],
date?: DateTime,
selectedOption?: ZoneOption,
) {
const offset = date?.offset;
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
let sameAsUserTimeZone;
let firstWithSameOffset;
if (offset !== undefined) {
sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
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;
}
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 sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
let offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes;
if (offsetDifference != 0) {
return offsetDifference;
}
return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
}
const handleConfirm = () => {
if (!showRelative && date.isValid && selectedAbsoluteOption) {
// Get the local date/time components from the selected string using neutral timezone
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
// Determine the modern, DST-aware offset for the selected IANA zone
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedAbsoluteOption.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.
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! });
}
if (showRelative && (selectedDuration || selectedRelativeOption)) {
onConfirm({ mode: 'relative', duration: selectedDuration, timeZone: selectedRelativeOption?.value });
}
};
const handleOnSelect = (option?: ComboBoxOption) => {
if (showRelative) {
selectedRelativeOption = option
? getPreferredTimeZone(userTimeZone, timezones, undefined, option as ZoneOption)
: undefined;
} else {
if (option) {
selectedAbsoluteOption = getPreferredTimeZone(userTimeZone, timezones, initialDate, option as ZoneOption);
}
}
};
let selectedOption = $derived(showRelative ? selectedRelativeOption : selectedAbsoluteOption);
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedAbsoluteOption?.value, setZone: true }));
export function calcNewDate(timestamp: DateTime, selectedDuration: number, timezone?: string) {
timestamp = timestamp.plus({ minutes: selectedDuration });
if (timezone) {
timestamp = timestamp.setZone(timezone);
}
return getDateTimeOffsetLocaleString(timestamp, { locale: get(locale) });
}
let intervalFrom = $derived.by(() =>
currentInterval ? calcNewDate(currentInterval.start, selectedDuration, selectedRelativeOption?.value) : undefined,
);
let intervalTo = $derived.by(() =>
currentInterval ? calcNewDate(currentInterval.end, selectedDuration, selectedRelativeOption?.value) : undefined,
);
</script>
<ConfirmModal
confirmColor="primary"
{title}
icon={mdiCalendarEditOutline}
prompt="Please select a new date:"
disabled={!date.isValid}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
>
{#snippet promptSnippet()}
{#if withDuration}
<div class="mb-5">
<Field label={$t('edit_date_and_time_by_offset')}>
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} />
</Field>
</div>
{/if}
<div class="flex flex-col text-start min-h-[140px]">
<div>
<div class="flex flex-col" style="display: {showRelative ? 'none' : 'flex'}">
<label for="datetime">{$t('date_and_time')}</label>
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
</div>
<div class="flex flex-col" style="display: {showRelative ? 'flex' : 'none'}">
<div class="flex flex-col">
<label for="relativedatetime">{$t('offset')}</label>
<DurationInput class="immich-form-input" id="relativedatetime" bind:value={selectedDuration} />
</div>
</div>
{#if timezoneInput}
<div>
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => handleOnSelect(option)}
/>
</div>
{/if}
<div class="flex flex-col" style="display: {showRelative && currentInterval ? 'flex' : 'none'}">
<span data-testid="interval-preview"
>{$t('edit_date_and_time_by_offset_interval', { values: { from: intervalFrom, to: intervalTo } })}</span
>
</div>
</div>
</div>
{/snippet}
</ConfirmModal>

View file

@ -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 @@
</script>
<svelte:window onresize={onPositionChange} />
<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''}" for={inputId}>{label}</Label>
<div
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
use:focusOutside={{ onFocusOut: deactivate }}

View file

@ -61,7 +61,7 @@
<div
bind:clientHeight={height}
class="fixed min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
class="fixed min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg z-1"
style:left="{left}px"
style:top="{top}px"
transition:slide={{ duration: 250, easing: quintOut }}

View file

@ -1,18 +1,11 @@
<script lang="ts">
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();
};
</script>
{#if menuItem}
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
{/if}
{#if isShowChangeDate}
<ChangeDate
initialDate={DateTime.now()}
{currentInterval}
onConfirm={handleConfirm}
onCancel={() => (isShowChangeDate = false)}
/>
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={handleChangeDate} />
{/if}

View file

@ -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 } });

View file

@ -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 } });

View file

@ -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}
<ChangeDate
withDuration={false}
title="Navigate to Time"
initialDate={DateTime.now()}
timezoneInput={false}
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
isShowSelectDate = false;
if (dateString.mode == 'absolute') {
const asset = await timelineManager.getClosestAssetToDate(
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}

View file

@ -1,7 +1,15 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { Icon } from '@immich/ui';
import { mdiContentDuplicate, mdiCrosshairsGps, mdiImageSizeSelectLarge } from '@mdi/js';
import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Icon, modalManager } from '@immich/ui';
import {
mdiCellphoneArrowDownVariant,
mdiContentDuplicate,
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
} from '@mdi/js';
import { t } from 'svelte-i18n';
const links = [
@ -21,3 +29,27 @@
</a>
{/each}
</div>
<br />
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="uppercase text-xs font-medium p-4">{$t('download')}</p>
<button
type="button"
onclick={() => modalManager.show(ObtainiumConfigModal, {})}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span>
<Icon icon={mdiLinkEdit} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('obtainium_configurator')}
</button>
<button
type="button"
onclick={() => modalManager.show(AppDownloadModal, {})}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span>
<Icon icon={mdiCellphoneArrowDownVariant} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('app_download_links')}
</button>
</div>

View file

@ -1,5 +1,7 @@
<script lang="ts">
interface Props {
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends HTMLInputAttributes {
type: 'date' | 'datetime-local';
value?: string;
min?: string;

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import type { MonthGroup } from '../month-group.svelte';
import { findClosestGroupForDate } from './search-support.svelte';
function createMockMonthGroup(year: number, month: number): MonthGroup {
return {
yearMonth: { year, month },
} as MonthGroup;
}
describe('findClosestGroupForDate', () => {
it('should return undefined for empty months array', () => {
const result = findClosestGroupForDate([], { year: 2024, month: 1 });
expect(result).toBeUndefined();
});
it('should return the only month when there is only one month', () => {
const months = [createMockMonthGroup(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should return exact match when available', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
const result = findClosestGroupForDate(months, { year: 2024, month: 6 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should find closest month when target is between two months', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
const result = findClosestGroupForDate(months, { year: 2024, month: 4 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => {
const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)];
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
// 2024-01 is 1 month from 2023-12 and 1 month from 2024-02
// Should return first encountered with min distance (2023-12)
expect(result?.yearMonth).toEqual({ year: 2023, month: 12 });
});
it('should correctly calculate distance across years', () => {
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2023, month: 6 });
// Both are exactly 12 months away, should return first encountered
expect(result?.yearMonth).toEqual({ year: 2022, month: 6 });
});
it('should handle target before all months', () => {
const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should handle target after all months', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should handle multiple years correctly', () => {
const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)];
const result = findClosestGroupForDate(months, { year: 2023, month: 1 });
// 2023-01 is 12 months from 2022-01 and 12 months from 2024-01
expect(result?.yearMonth).toEqual({ year: 2022, month: 1 });
});
it('should prefer closer month when one is clearly closer', () => {
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)];
const result = findClosestGroupForDate(months, { year: 2024, month: 11 });
// 2024-11 is 1 month from 2024-10 and 10 months from 2024-01
expect(result?.yearMonth).toEqual({ year: 2024, month: 10 });
});
});

View file

@ -1,5 +1,6 @@
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { DateTime } from 'luxon';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
@ -143,3 +144,22 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
}
}
}
export function findClosestGroupForDate(months: MonthGroup[], targetYearMonth: TimelineYearMonth) {
const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month });
let closestMonth: MonthGroup | undefined;
let minDifference = Number.MAX_SAFE_INTEGER;
for (const month of months) {
const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month });
const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months);
if (totalDiff < minDifference) {
minDifference = totalDiff;
closestMonth = month;
}
}
return closestMonth;
}

View file

@ -16,6 +16,7 @@ import {
runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import {
findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil,
findMonthGroupForDate,
getAssetWithOffset,
@ -584,9 +585,13 @@ export class TimelineManager {
}
async getClosestAssetToDate(dateTime: TimelineDateTime) {
const monthGroup = findMonthGroupForDate(this, dateTime);
let monthGroup = findMonthGroupForDate(this, dateTime);
if (!monthGroup) {
return;
// if exact match not found, find closest
monthGroup = findClosestGroupForDate(this.months, dateTime);
if (!monthGroup) {
return;
}
}
await this.loadMonthGroup(dateTime, { cancelable: false });
const asset = monthGroup.findClosest(dateTime);

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { Button, HStack, Modal, ModalBody, ModalFooter, Text, Textarea } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -14,16 +14,8 @@
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
<ModalBody>
<div class="text-primary">
<p class="text-sm dark:text-immich-dark-fg">
{$t('api_key_description')}
</p>
</div>
<div class="my-4 flex flex-col gap-2">
<!-- <label class="immich-form-label" for="secret">{ $t("api_key") }</label> -->
<textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret}></textarea>
</div>
<Text size="small" class="mb-4">{$t('api_key_description')}</Text>
<Textarea bind:value={secret} readonly />
</ModalBody>
<ModalFooter>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<Modal title={$t('app_download_links')} size="large" {onClose}>
<ModalBody>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="fdroid-link">
F-Droid
</label>
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
<img class="pt-2 pr-10" alt="Get it on F-Droid" src={fdroidBadge} />
</a>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="play-store-link">
Google Play
</label>
<a
href="https://play.google.com/store/apps/details?id=app.alextran.immich"
target="_blank"
id="play-store-link"
>
<img alt="Get it on Google Play" src={playStoreBadge} />
</a>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="app-store-link">
App Store
</label>
<a href="https://apps.apple.com/us/app/immich/id1613945652" target="_blank" id="app-store-link">
<img class="pt-2 pr-5" alt="Download on the App Store" src={appStoreBadge} width="90%" />
</a>
</div>
</div>
</ModalBody>
</Modal>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import Combobox from '$lib/components/shared-components/combobox.svelte';
import DateInput from '$lib/elements/DateInput.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toIsoDate } from '$lib/modals/timezone-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset } from '@immich/sdk';
import { Button, HStack, Label, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
initialDate?: DateTime;
initialTimeZone?: string;
timezoneInput?: boolean;
asset: TimelineAsset;
onClose: (success: boolean) => void;
}
let { initialDate = DateTime.now(), initialTimeZone, timezoneInput = true, asset, onClose }: Props = $props();
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
const timezones = $derived(getTimezones(selectedDate));
// svelte-ignore state_referenced_locally
let lastSelectedTimezone = $state(getPreferredTimeZone(initialDate, initialTimeZone, timezones));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
const handleClose = async () => {
if (!date.isValid || !selectedOption) {
onClose(false);
return;
}
// Get the local date/time components from the selected string using neutral timezone
const isoDate = toIsoDate(selectedDate, selectedOption);
try {
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: isoDate } });
onClose(true);
} catch (error) {
handleError(error, $t('errors.unable_to_change_date'));
onClose(false);
}
};
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
<ModalBody>
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput
class="immich-form-input text-gray-700 w-full mb-2"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
{#if timezoneInput}
<div class="w-full">
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
</div>
{/if}
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View file

@ -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,
);
}
});
});

View file

@ -0,0 +1,128 @@
<script lang="ts">
import Combobox from '$lib/components/shared-components/combobox.svelte';
import DateInput from '$lib/elements/DateInput.svelte';
import DurationInput from '$lib/elements/DurationInput.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toIsoDate, type ZoneOption } from '$lib/modals/timezone-utils';
import { user } from '$lib/stores/user.store';
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { Button, Field, HStack, Label, Modal, ModalBody, ModalFooter, Switch } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
initialDate?: DateTime;
initialTimeZone?: string;
assets: TimelineAsset[];
onClose: (success: boolean) => void;
}
let { initialDate = DateTime.now(), initialTimeZone, assets, onClose }: Props = $props();
let showRelative = $state(false);
let selectedDuration = $state(0);
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
const timezones = $derived(getTimezones(selectedDate));
// svelte-ignore state_referenced_locally
let lastSelectedTimezone = $state(getPreferredTimeZone(initialDate, initialTimeZone, timezones));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
const handleConfirm = async () => {
const ids = getOwnedAssetsWithWarning(assets, $user);
try {
if (showRelative && (selectedDuration || selectedOption)) {
await updateAssets({
assetBulkUpdateDto: {
ids,
dateTimeRelative: selectedDuration,
timeZone: selectedOption?.value,
},
});
onClose(true);
return;
}
const isoDate = toIsoDate(selectedDate, selectedOption);
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: isoDate } });
onClose(true);
} catch (error) {
handleError(error, $t('errors.unable_to_change_date'));
onClose(false);
}
};
// let before = $derived(DateTime.fromObject(assets[0].localDateTime).toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
// let after = $derived(
// currentInterval ? calcNewDate(currentInterval.end, selectedDuration, selectedOption?.value) : undefined,
// );
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('edit_date_and_time')} icon={mdiCalendarEdit} onClose={() => onClose(false)} size="small">
<ModalBody>
<Field label={$t('edit_date_and_time_by_offset')}>
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} class="mb-2" />
</Field>
{#if showRelative}
<Label for="relativedatetime" class="block mb-1">{$t('offset')}</Label>
<DurationInput
class="immich-form-input w-full text-gray-700 mb-2"
id="relativedatetime"
bind:value={selectedDuration}
/>
{:else}
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
{/if}
<div class="w-full">
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => (lastSelectedTimezone = option as ZoneOption)}
></Combobox>
</div>
<!-- <Card color="secondary" class={!showRelative || !currentInterval ? 'invisible' : ''}>
<CardBody class="p-2">
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center">
<div class="col-span-2 immich-form-label" data-testid="interval-preview">Preview</div>
<Text size="small" class="-mt-2 immich-form-label col-span-2"
>Showing changes for first selected asset only</Text
>
<label class="immich-form-label" for="from">Before</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="from"
type="datetime-local"
readonly
bind:value={before}
/>
<label class="immich-form-label" for="to">After</label>
<DateInput
class="dark:text-gray-300 text-gray-700 text-base"
id="to"
type="datetime-local"
readonly
bind:value={after}
/>
</div>
</CardBody>
</Card> -->
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
{$t('cancel')}
</Button>
<Button shape="round" color="primary" fullWidth onclick={handleConfirm} disabled={!date.isValid}>
{$t('confirm')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,61 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
import { mdiNavigationVariantOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
timelineManager: TimelineManager;
onClose: (asset?: TimelineAsset) => void;
}
let { timelineManager, onClose }: Props = $props();
const initialDate = DateTime.now();
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
const timezones = $derived(getTimezones(selectedDate));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption: ZoneOption | undefined = $derived(getPreferredTimeZone(initialDate, undefined, timezones));
const handleClose = async () => {
if (!date.isValid || !selectedOption) {
onClose();
return;
}
// Get the local date/time components from the selected string using neutral timezone
const dateTime = toDatetime(selectedDate, selectedOption) as DateTime<true>;
const asset = await timelineManager.getClosestAssetToDate(dateTime.toObject());
onClose(asset);
};
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
<Modal title={$t('navigate_to_time')} icon={mdiNavigationVariantOutline} onClose={() => onClose()}>
<ModalBody>
<VStack fullWidth>
<HStack fullWidth>
<label class="immich-form-label" for="datetime">{$t('date_and_time')}</label>
</HStack>
<HStack fullWidth>
<DateInput
class="immich-form-input text-gray-700 w-full"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={handleClose}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { createApiKey, Permission } from '@immich/sdk';
import { Button, Modal, ModalBody, obtainiumBadge } from '@immich/ui';
import { t } from 'svelte-i18n';
let inputUrl = $state(location.origin);
let inputApiKey = $state('');
let archVariant = $state('');
let obtainiumLink = $derived(
`https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.alextran.immich%22%2C%22url%22%3A%22${inputUrl}%2Fapi%2Fserver%2Fapk-links%22%2C%22author%22%3A%22Immich%22%2C%22name%22%3A%22Immich%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%2C%7B%5C%22requestHeader%5C%22%3A%5C%22x-api-key%3A%20${inputApiKey}%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%2Fv(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B)%2F%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%241.%242.%243%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22app-${archVariant}.apk%24%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D`,
);
const handleCreate = async () => {
try {
const { secret } = await createApiKey({
apiKeyCreateDto: {
name: 'Obtainium',
permissions: [Permission.ServerApkLinks],
},
});
inputApiKey = secret;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
}
};
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<Modal title={$t('obtainium_configurator')} size="large" {onClose}>
<ModalBody>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
<div>
<label
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
for="obtainium-configurator"
>
Obtainium
</label>
<div id="obtainium-configurator">
<form>
<div class="mt-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
</div>
<div class="mt-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('api_key')}
bind:value={inputApiKey}
/>
</div>
<div class="">
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
</div>
<div class="mt-2">
<SettingSelect
label={$t('app_architecture_variant')}
bind:value={archVariant}
options={[
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
{ value: 'release', text: 'universal' },
{ value: 'x86_64-release', text: 'x86_64' },
]}
/>
</div>
</form>
</div>
</div>
<div class="content-center">
{#if inputUrl && inputApiKey && archVariant}
<a
href={obtainiumLink}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="obtainium-link"
>
<img class="pt-2 pr-5" alt="Get it on Obtainium" src={obtainiumBadge} />
</a>
{:else}
<p class="immich-form-label pb-2 text-sm" id="obtainium-link">
{$t('obtainium_configurator_instructions')}
</p>
{/if}
</div>
</div>
</ModalBody>
</Modal>

View file

@ -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') },

View file

@ -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");
};

View file

@ -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;

View file

@ -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;

View file

@ -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);