Compare commits

..

14 Commits

Author SHA1 Message Date
shenlong-tanwen
f0e644df23 rebase 2026-07-22 02:58:52 +05:30
shenlong-tanwen
19640d4910 add map codec tests 2026-07-22 02:58:52 +05:30
Yaros
802f81e8e8 chore: locale toLanguageTag 2026-07-22 02:58:52 +05:30
Yaros
dd53f8a215 refactor: move model to domain 2026-07-22 02:58:52 +05:30
Yaros
d685038db8 refactor: move options to mapconfig 2026-07-22 02:58:52 +05:30
Yaros
ce853507eb chore: restrict selection 2026-07-22 02:58:52 +05:30
Yaros
a20df051fe fix: context.locale 2026-07-22 02:58:52 +05:30
Yaros
d25237471b fix: ifPresent 2026-07-22 02:58:52 +05:30
Yaros
cc77ea01aa refactor: suggestions 2026-07-22 02:58:52 +05:30
Yaros
32b5c6ca75 refactor: implement suggestions 2026-07-22 02:58:52 +05:30
Yaros
fd72f14b47 refactor: add back setRelativeTime 2026-07-22 02:58:52 +05:30
Yaros
0d847aadca refactor: rename customtimerange variables 2026-07-22 02:58:52 +05:30
Yaros
8c176f7f13 refactor: rename timerange & remove isvalid 2026-07-22 02:58:52 +05:30
Yaros
f69defe1ac feat(mobile): custom date range for map
# Conflicts:
#	mobile/lib/domain/models/store.model.dart
#	mobile/lib/presentation/widgets/map/map.state.dart
#	mobile/lib/services/app_settings.service.dart
2026-07-22 02:58:52 +05:30
58 changed files with 656 additions and 693 deletions

View File

@@ -106,7 +106,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
@@ -241,7 +241,7 @@ jobs:
run: flutter build ios --config-only --no-codesign run: flutter build ios --config-only --no-codesign
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0 uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with: with:
ruby-version: '3.3' ruby-version: '3.3'
bundler-cache: true bundler-cache: true

View File

@@ -25,7 +25,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6 uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json

View File

@@ -39,7 +39,7 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
outputs: outputs:
checked: ${{ steps.get_checkbox.outputs.checked }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,7 +71,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
# Command-line programs to run using the OS shell. # 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 # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@@ -152,7 +152,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with: with:
draft: true draft: true
prerelease: ${{ needs.bump_version.outputs.rc }} prerelease: ${{ needs.bump_version.outputs.rc }}

View File

@@ -1,6 +1,6 @@
[tools] [tools]
terragrunt = "1.1.1" terragrunt = "1.0.3"
opentofu = "1.12.4" opentofu = "1.11.6"
[tasks."tg:fmt"] [tasks."tg:fmt"]
run = "terragrunt hclfmt" run = "terragrunt hclfmt"

View File

@@ -2,35 +2,37 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.8" version = "4.52.7"
constraints = "4.52.8" constraints = "4.52.7"
hashes = [ hashes = [
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=", "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=", "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=", "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=", "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=", "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=", "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=", "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=", "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=", "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=", "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=", "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=", "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=", "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477", "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d", "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363", "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74", "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f", "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0", "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b", "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30", "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6", "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91", "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29", "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb", "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11", "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
] ]
} }

View File

@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.8" version = "4.52.7"
} }
} }
} }

View File

@@ -2,35 +2,37 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.8" version = "4.52.7"
constraints = "4.52.8" constraints = "4.52.7"
hashes = [ hashes = [
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=", "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=", "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=", "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=", "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=", "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=", "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=", "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=", "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=", "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=", "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=", "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=", "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=", "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477", "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d", "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363", "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74", "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f", "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0", "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b", "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30", "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6", "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91", "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29", "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb", "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11", "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
] ]
} }

View File

@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.8" version = "4.52.7"
} }
} }
} }

View File

@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.4.5-ubuntu@sha256:00396460e499415c828b7c298f19287c8a0f95e72412ee37ac11723655c2d6b9 image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -35,7 +35,6 @@ Found Admin:
- Email=admin@example.com - Email=admin@example.com
- Name=Immich Admin - Name=Immich Admin
? Please choose a new password (optional) immich-is-cool ? Please choose a new password (optional) immich-is-cool
? Invalidate existing sessions? Yes
The admin password has been updated. The admin password has been updated.
``` ```

View File

@@ -650,7 +650,7 @@ describe(`immich upload`, () => {
]); ]);
expect(stdout).toBe(''); expect(stdout).toBe('');
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`); expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`);
expect(exitCode).not.toBe(0); expect(exitCode).not.toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });

View File

@@ -65,10 +65,6 @@ describe(`immich-admin`, () => {
child.stdout.on('data', (chunk) => { child.stdout.on('data', (chunk) => {
data += chunk; data += chunk;
if (data.includes('Please choose a new password (optional)')) { if (data.includes('Please choose a new password (optional)')) {
child.stdin.write('\n');
}
if (data.includes('Invalidate existing sessions?')) {
child.stdin.end('\n'); child.stdin.end('\n');
} }
}); });

View File

@@ -1524,6 +1524,7 @@
"not_available": "N/A", "not_available": "N/A",
"not_in_any_album": "Not in any album", "not_in_any_album": "Not in any album",
"not_selected": "Not selected", "not_selected": "Not selected",
"not_set": "Not set",
"notes": "Notes", "notes": "Notes",
"nothing_here_yet": "Nothing here yet", "nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability", "notification_backup_reliability": "Enable notifications to improve background backup reliability",

132
mise.lock
View File

@@ -94,23 +94,6 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
version = "7.1.3-6" version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options] [tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
@@ -129,12 +112,17 @@ version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options] [tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"] [tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2" checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz" url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]] [[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6" version = "7.1.3-6"
@@ -145,6 +133,18 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip" url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[[tools."github:webassembly/binaryen"]] [[tools."github:webassembly/binaryen"]]
version = "version_124" version = "version_124"
backend = "github:webassembly/binaryen" backend = "github:webassembly/binaryen"
@@ -248,43 +248,43 @@ version = "7.5.0"
backend = "npm:oazapfts" backend = "npm:oazapfts"
[[tools.opentofu]] [[tools.opentofu]]
version = "1.12.4" version = "1.11.6"
backend = "aqua:opentofu/opentofu" backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"] [tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a" checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
[tools.opentofu."platforms.linux-arm64-musl"] [tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a" checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
[tools.opentofu."platforms.linux-x64"] [tools.opentofu."platforms.linux-x64"]
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24" checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
[tools.opentofu."platforms.linux-x64-musl"] [tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24" checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
[tools.opentofu."platforms.macos-arm64"] [tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:7c06e4390d9ccd467773e37ff1c3d833c7ca0c24742cd9e9ad47284bea472247" checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646544" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399"
[tools.opentofu."platforms.macos-x64"] [tools.opentofu."platforms.macos-x64"]
checksum = "sha256:ead1d2ce643addb4ffeb93240b9377ae1c2fd793a6bd22d65922ac37adfdf546" checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646688" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384"
[tools.opentofu."platforms.windows-x64"] [tools.opentofu."platforms.windows-x64"]
checksum = "sha256:a4d86a07755c8d151f20f945e6cfb5b40deeed942af36a9bd385c5c2e965d5dd" checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_windows_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646543" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
[[tools.pnpm]] [[tools.pnpm]]
version = "11.13.1" version = "11.13.1"
@@ -327,40 +327,40 @@ url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981"
provenance = "github-attestations" provenance = "github-attestations"
[[tools.terragrunt]] [[tools.terragrunt]]
version = "1.1.1" version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt" backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"] [tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2" checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
[tools.terragrunt."platforms.linux-arm64-musl"] [tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2" checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
[tools.terragrunt."platforms.linux-x64"] [tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53" checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
[tools.terragrunt."platforms.linux-x64-musl"] [tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53" checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
[tools.terragrunt."platforms.macos-arm64"] [tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794" checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
[tools.terragrunt."platforms.macos-x64"] [tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2" checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
[tools.terragrunt."platforms.windows-x64"] [tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115" checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"

View File

@@ -1,4 +1,4 @@
monorepo_root = true experimental_monorepo_root = true
[monorepo] [monorepo]
config_roots = [ config_roots = [
@@ -17,8 +17,8 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
pnpm = "11.13.1" pnpm = "11.13.1"
terragrunt = "1.1.1" terragrunt = "1.0.3"
opentofu = "1.12.4" opentofu = "1.11.6"
"npm:oazapfts" = "7.5.0" "npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3" "github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124" "github:webassembly/binaryen" = "version_124"
@@ -35,6 +35,7 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" } macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
[settings] [settings]
experimental = true
pin = true pin = true
lockfile = true lockfile = true

View File

@@ -203,10 +203,7 @@ class ImmichAPI {
func fetchMemory(for date: Date) async throws -> [MemoryResult] { func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL // get URL
let localDay = date.formatted( let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
Date.ISO8601FormatStyle(timeZone: .current).year().month().day().dateSeparator(.dash)
)
let memoryParams = [URLQueryItem(name: "for", value: localDay)]
guard guard
let searchURL = buildRequestURL( let searchURL = buildRequestURL(
serverConfig: serverConfig, serverConfig: serverConfig,

View File

@@ -6,7 +6,6 @@ const Map<String, Locale> locales = {
// Additional locales // Additional locales
'Arabic (ar)': Locale('ar'), 'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'), 'Basque (eu)': Locale('eu'),
'Belarusian (be)': Locale('be'),
'Bosnian (bl)': Locale('bn'), 'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'), 'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'), 'Bulgarian (bg)': Locale('bg'),

View File

@@ -153,6 +153,8 @@ class AppConfig {
.timelineStorageIndicator => timeline.storageIndicator, .timelineStorageIndicator => timeline.storageIndicator,
.mapShowFavoriteOnly => map.favoritesOnly, .mapShowFavoriteOnly => map.favoritesOnly,
.mapRelativeDate => map.relativeDays, .mapRelativeDate => map.relativeDays,
.mapCustomFrom => map.customFrom,
.mapCustomTo => map.customTo,
.mapIncludeArchived => map.includeArchived, .mapIncludeArchived => map.includeArchived,
.mapThemeMode => map.themeMode, .mapThemeMode => map.themeMode,
.mapWithPartners => map.withPartners, .mapWithPartners => map.withPartners,
@@ -207,6 +209,8 @@ class AppConfig {
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)), .timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)), .mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)), .mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
.mapCustomTo => copyWith(map: map.copyWith(customTo: .fromNullable(value as DateTime?))),
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)), .mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)), .mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)), .mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/option.dart';
class MapConfig { class MapConfig {
final int relativeDays; final int relativeDays;
@@ -6,13 +7,17 @@ class MapConfig {
final bool includeArchived; final bool includeArchived;
final ThemeMode themeMode; final ThemeMode themeMode;
final bool withPartners; final bool withPartners;
final DateTime? customFrom;
final DateTime? customTo;
const MapConfig({ const MapConfig({
this.relativeDays = 0, this.relativeDays = 0,
this.favoritesOnly = false, this.favoritesOnly = false,
this.includeArchived = false, this.includeArchived = false,
this.themeMode = ThemeMode.system, this.themeMode = .system,
this.withPartners = false, this.withPartners = false,
this.customFrom,
this.customTo,
}); });
MapConfig copyWith({ MapConfig copyWith({
@@ -21,12 +26,16 @@ class MapConfig {
bool? includeArchived, bool? includeArchived,
ThemeMode? themeMode, ThemeMode? themeMode,
bool? withPartners, bool? withPartners,
Option<DateTime>? customFrom,
Option<DateTime>? customTo,
}) => MapConfig( }) => MapConfig(
relativeDays: relativeDays ?? this.relativeDays, relativeDays: relativeDays ?? this.relativeDays,
favoritesOnly: favoritesOnly ?? this.favoritesOnly, favoritesOnly: favoritesOnly ?? this.favoritesOnly,
includeArchived: includeArchived ?? this.includeArchived, includeArchived: includeArchived ?? this.includeArchived,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
withPartners: withPartners ?? this.withPartners, withPartners: withPartners ?? this.withPartners,
customFrom: customFrom.patch(this.customFrom),
customTo: customTo.patch(this.customTo),
); );
@override @override
@@ -37,12 +46,15 @@ class MapConfig {
other.favoritesOnly == favoritesOnly && other.favoritesOnly == favoritesOnly &&
other.includeArchived == includeArchived && other.includeArchived == includeArchived &&
other.themeMode == themeMode && other.themeMode == themeMode &&
other.withPartners == withPartners); other.withPartners == withPartners &&
other.customFrom == customFrom &&
other.customTo == customTo);
@override @override
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners); int get hashCode =>
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
@override @override
String toString() => String toString() =>
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)'; 'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
} }

View File

@@ -55,6 +55,8 @@ enum SettingsKey<T> {
// Map // Map
mapShowFavoriteOnly<bool>(), mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(), mapRelativeDate<int>(),
mapCustomFrom<DateTime?>(),
mapCustomTo<DateTime?>(),
mapIncludeArchived<bool>(), mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)), mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(), mapWithPartners<bool>(),

View File

@@ -0,0 +1,15 @@
import 'package:immich_mobile/utils/option.dart';
class TimeRange {
final DateTime? from;
final DateTime? to;
const TimeRange({this.from, this.to});
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
return TimeRange(from: from.patch(this.from), to: to.patch(this.to));
}
TimeRange clearFrom() => TimeRange(to: to);
TimeRange clearTo() => TimeRange(from: from);
}

View File

@@ -27,7 +27,18 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true); condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
} }
if (options.relativeDays != 0) { final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!);
}
if (timeRange.to != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!);
}
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate); condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
} }

View File

@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -20,6 +21,7 @@ class TimelineMapOptions {
final bool includeArchived; final bool includeArchived;
final bool withPartners; final bool withPartners;
final int relativeDays; final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({ const TimelineMapOptions({
required this.bounds, required this.bounds,
@@ -27,6 +29,7 @@ class TimelineMapOptions {
this.includeArchived = false, this.includeArchived = false,
this.withPartners = false, this.withPartners = false,
this.relativeDays = 0, this.relativeDays = 0,
this.timeRange = const TimeRange(),
}); });
} }
@@ -552,8 +555,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true)); query.where(_db.remoteAssetEntity.isFavorite.equals(true));
} }
if (options.relativeDays != 0) { final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
}
if (timeRange.to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
}
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
} }
@@ -594,8 +610,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true)); query.where(_db.remoteAssetEntity.isFavorite.equals(true));
} }
if (options.relativeDays != 0) { final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
}
if (timeRange.to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
}
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
} }

View File

@@ -886,6 +886,7 @@ class _QuickLinkList extends StatelessWidget {
_QuickLink( _QuickLink(
title: context.t.recently_added, title: context.t.recently_added,
icon: Icons.upload_outlined, icon: Icons.upload_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()), onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
), ),
_QuickLink( _QuickLink(

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
class MapState { class MapState {
@@ -15,6 +17,7 @@ class MapState {
final bool includeArchived; final bool includeArchived;
final bool withPartners; final bool withPartners;
final int relativeDays; final int relativeDays;
final TimeRange timeRange;
const MapState({ const MapState({
this.themeMode = ThemeMode.system, this.themeMode = ThemeMode.system,
@@ -23,6 +26,7 @@ class MapState {
this.includeArchived = false, this.includeArchived = false,
this.withPartners = false, this.withPartners = false,
this.relativeDays = 0, this.relativeDays = 0,
this.timeRange = const TimeRange(),
}); });
@override @override
@@ -40,6 +44,7 @@ class MapState {
bool? includeArchived, bool? includeArchived,
bool? withPartners, bool? withPartners,
int? relativeDays, int? relativeDays,
TimeRange? timeRange,
}) { }) {
return MapState( return MapState(
bounds: bounds ?? this.bounds, bounds: bounds ?? this.bounds,
@@ -48,6 +53,7 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived, includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners, withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays, relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
); );
} }
@@ -57,6 +63,7 @@ class MapState {
includeArchived: includeArchived, includeArchived: includeArchived,
withPartners: withPartners, withPartners: withPartners,
relativeDays: relativeDays, relativeDays: relativeDays,
timeRange: timeRange,
); );
} }
@@ -103,6 +110,24 @@ class MapStateNotifier extends Notifier<MapState> {
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
void setCustomTimeRange(TimeRange range) {
ref.read(settingsProvider).write(.mapCustomFrom, range.from);
ref.read(settingsProvider).write(.mapCustomTo, range.to);
state = state.copyWith(timeRange: range);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
Option<DateTime> parseDateOption(String s) {
try {
if (s.trim().isEmpty) {
return const Option.none();
}
return Option.some(DateTime.parse(s));
} catch (_) {
return const Option.none();
}
}
@override @override
MapState build() { MapState build() {
final mapConfig = ref.read(appConfigProvider.select((config) => config.map)); final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
@@ -113,6 +138,7 @@ class MapStateNotifier extends Notifier<MapState> {
withPartners: mapConfig.withPartners, withPartners: mapConfig.withPartners,
relativeDays: mapConfig.relativeDays, relativeDays: mapConfig.relativeDays,
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
); );
} }
} }

View File

@@ -1,21 +1,39 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends HookConsumerWidget { class DriftMapSettingsSheet extends ConsumerStatefulWidget {
const DriftMapSettingsSheet({super.key}); const DriftMapSettingsSheet({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
late bool useCustomRange;
@override
void initState() {
super.initState();
final mapState = ref.read(mapStateProvider);
final timeRange = mapState.timeRange;
useCustomRange = timeRange.from != null || timeRange.to != null;
}
@override
Widget build(BuildContext context) {
final mapState = ref.watch(mapStateProvider); final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet( return DraggableScrollableSheet(
expand: false, expand: false,
initialChildSize: 0.6, initialChildSize: useCustomRange ? 0.7 : 0.6,
builder: (ctx, scrollController) => SingleChildScrollView( builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController, controller: scrollController,
child: Card( child: Card(
@@ -47,10 +65,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget {
selected: mapState.withPartners, selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
), ),
MapTimeDropDown( if (useCustomRange) ...[
relativeTime: mapState.relativeDays, MapTimeRange(
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time), timeRange: mapState.timeRange,
), onChanged: (range) {
ref.read(mapStateProvider.notifier).setCustomTimeRange(range);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = false;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
}),
child: Text(context.t.remove_custom_date_range),
),
),
] else ...[
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = true;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
}),
child: Text(context.t.use_custom_date_range),
),
),
],
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),

View File

@@ -1,19 +1,8 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
@visibleForTesting
bool isRouteInStack(Ref ref, String routeName) {
final router = ref.watch(appRouterProvider);
void onChange() => ref.invalidateSelf();
router.addListener(onChange);
ref.onDispose(() => router.removeListener(onChange));
return router.stackData.any((route) => route.name == routeName);
}
final inLockedViewProvider = Provider<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null); final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null); final previousRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null); final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);

View File

@@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppNavigationObserver extends AutoRouterObserver { class AppNavigationObserver extends AutoRouterObserver {
/// Riverpod Instance /// Riverpod Instance
@@ -11,12 +12,46 @@ class AppNavigationObserver extends AutoRouterObserver {
AppNavigationObserver({required this.ref}); AppNavigationObserver({required this.ref});
@override
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false));
}
@override @override
void didPush(Route route, Route? previousRoute) { void didPush(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(route, previousRoute);
Future(() { Future(() {
ref.read(currentRouteNameProvider.notifier).state = route.settings.name; ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name; ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings; ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
if (route.settings.name == AssetViewerRoute.name) {
ref.read(isAssetViewerOpenProvider.notifier).state = true;
}
}); });
} }
@override
void didPop(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(previousRoute ?? route, null);
if (route.settings.name == AssetViewerRoute.name) {
Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false);
}
}
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
final isInLockedView = ref.read(inLockedViewProvider);
final isFromLockedViewToDetailView =
route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name;
final isFromDetailViewToInfoPanelView =
route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView;
if (route.settings.name == DriftLockedFolderRoute.name ||
isFromLockedViewToDetailView ||
isFromDetailViewToInfoPanelView) {
Future(() => ref.read(inLockedViewProvider.notifier).state = true);
} else {
Future(() => ref.read(inLockedViewProvider.notifier).state = false);
}
}
} }

View File

@@ -290,10 +290,13 @@ class BackgroundUploadService {
return null; return null;
} }
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those. final hasExtension = p.extension(fileName).isNotEmpty;
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); if (!hasExtension) {
final originalFileName = p.setExtension(fileName, extension); fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
String metadata = UploadTaskMetadata( String metadata = UploadTaskMetadata(
localAssetId: asset.id, localAssetId: asset.id,

View File

@@ -309,10 +309,17 @@ class ForegroundUploadService {
return; return;
} }
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); /// Handle special file name from DJI or Fusion app
final originalFileName = p.setExtension(fileName, extension); /// If the file name has no extension, likely due to special renaming template by specific apps
/// we append the original extension from the asset name
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
final fields = { final fields = {

View File

@@ -26,6 +26,17 @@ sealed class Option<T> {
None() => onNone(), None() => onNone(),
}; };
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
Some(:final value) => f(value),
None() => const Option.none(),
};
void ifPresent(void Function(T value) f) {
if (this case Some(:final value)) {
f(value);
}
}
@override @override
String toString() => switch (this) { String toString() => switch (this) {
Some(:final value) => 'Some($value)', Some(:final value) => 'Some($value)',

View File

@@ -10,24 +10,6 @@ String sanitizeUrl(String url) {
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), ""); return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
} }
/// Validates a user-entered server URL
bool isValidServerUrl(String? url) {
if (url == null || url.isEmpty) {
return true;
}
if (!url.contains('://')) {
// Prepend conforming scheme for URL validation
// Uri.tryParse will not parse out host without a scheme provided, even though we assume http(s) when connecting
url = 'http://$url';
}
final parsedUrl = Uri.tryParse(url);
return parsedUrl != null &&
(parsedUrl.scheme.isEmpty || parsedUrl.scheme.startsWith(RegExp(r'https?'))) &&
parsedUrl.host.isNotEmpty;
}
String? getServerUrl() { String? getServerUrl() {
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint)); final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null; final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;

View File

@@ -43,7 +43,18 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm'); final log = Logger('LoginForm');
String? _validateUrl(String? url) => isValidServerUrl(url) ? null : 'login_form_err_invalid_url'.tr(); String? _validateUrl(String? url) {
if (url == null || url.isEmpty) {
return null;
}
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateEmail(String? email) { String? _validateEmail(String? email) {
if (email == null || email == '') { if (email == null || email == '') {

View File

@@ -0,0 +1,73 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/option.dart';
class MapTimeRange extends StatelessWidget {
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
final TimeRange timeRange;
final Function(TimeRange) onChanged;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(context.t.date_after),
subtitle: Text(
timeRange.from != null
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.from!)
: context.t.not_set,
),
trailing: timeRange.from != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
: null,
onTap: () async {
final initial = timeRange.from ?? DateTime.now();
final currentTo = timeRange.to;
final picked = await showDatePicker(
context: context,
initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial,
firstDate: DateTime(1970),
lastDate: currentTo ?? DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(from: Option.some(picked)));
}
},
),
ListTile(
title: Text(context.t.date_before),
subtitle: Text(
timeRange.to != null
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.to!)
: context.t.not_set,
),
trailing: timeRange.to != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
: null,
onTap: () async {
final initial = timeRange.to ?? DateTime.now();
final currentFrom = timeRange.from;
final picked = await showDatePicker(
context: context,
initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial,
firstDate: currentFrom ?? DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(to: Option.some(picked)));
}
},
),
],
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -9,7 +10,6 @@ import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -255,11 +255,11 @@ class _BackupDelaySlider extends ConsumerWidget {
_ => 600, _ => 600,
}; };
static String formatBackupDelaySliderValue(BuildContext context, int v) => switch (v) { static String formatBackupDelaySliderValue(int v) => switch (v) {
0 => context.t.setting_notifications_notify_seconds(count: 5), 0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
1 => context.t.setting_notifications_notify_seconds(count: 30), 1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
2 => context.t.setting_notifications_notify_minutes(count: 2), 2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
_ => context.t.setting_notifications_notify_minutes(count: 10), _ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
}; };
@override @override
@@ -272,8 +272,8 @@ class _BackupDelaySlider extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.only(left: 24.0, top: 8.0), padding: const EdgeInsets.only(left: 24.0, top: 8.0),
child: Text( child: Text(
context.t.backup_controller_page_background_delay( 'backup_controller_page_background_delay'.tr(
duration: formatBackupDelaySliderValue(context, currentValue), namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
), ),
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
), ),
@@ -291,7 +291,7 @@ class _BackupDelaySlider extends ConsumerWidget {
max: 3.0, max: 3.0,
min: 0.0, min: 0.0,
divisions: 3, divisions: 3,
label: formatBackupDelaySliderValue(context, currentValue), label: formatBackupDelaySliderValue(currentValue),
), ),
], ],
); );

View File

@@ -77,40 +77,6 @@ void main() {
}); });
}); });
group('isValidServerUrl', () {
test('should treat null as valid', () {
expect(isValidServerUrl(null), isTrue);
});
test('should treat empty string as valid', () {
expect(isValidServerUrl(''), isTrue);
});
test('should accept a bare host', () {
expect(isValidServerUrl('demo.immich.app'), isTrue);
});
test('should accept a bare host with a port', () {
expect(isValidServerUrl('192.168.1.1:2283'), isTrue);
});
test('should accept an http URL', () {
expect(isValidServerUrl('http://demo.immich.app'), isTrue);
});
test('should accept an https URL', () {
expect(isValidServerUrl('https://demo.immich.app:2283/api'), isTrue);
});
test('should reject a non-http scheme', () {
expect(isValidServerUrl('ftp://demo.immich.app'), isFalse);
});
test('should reject scheme only input', () {
expect(isValidServerUrl('https://'), isFalse);
});
});
group('punycodeDecodeUrl', () { group('punycodeDecodeUrl', () {
test('should return null for null input', () { test('should return null for null input', () {
expect(punycodeDecodeUrl(null), isNull); expect(punycodeDecodeUrl(null), isNull);

View File

@@ -25,6 +25,8 @@ class _FrozenBucketService implements TimelineService {
} }
class _EmptyBucketService implements TimelineService { class _EmptyBucketService implements TimelineService {
const _EmptyBucketService();
@override @override
Stream<List<Bucket>> Function() get watchBuckets => Stream<List<Bucket>> Function() get watchBuckets =>
() => Stream.value(const []); () => Stream.value(const []);
@@ -109,7 +111,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWithValue(_EmptyBucketService()), timelineServiceProvider.overrideWithValue(const _EmptyBucketService()),
appConfigProvider.overrideWithValue(const AppConfig()), appConfigProvider.overrideWithValue(const AppConfig()),
], ],
child: MaterialApp( child: MaterialApp(

View File

@@ -136,51 +136,6 @@ void main() {
expect(task, isNotNull); expect(task, isNotNull);
expect(task!.fields.containsKey('visibility'), isFalse); expect(task!.fields.containsKey('visibility'), isFalse);
}); });
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_6499.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_6499.jpg'));
});
test('keeps the .dng extension for a genuine RAW original', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_5210.dng');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_5210.dng'));
});
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/DJI_0001');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('DJI_0001.jpg'));
});
}); });
group('getLivePhotoUploadTask', () { group('getLivePhotoUploadTask', () {

View File

@@ -81,24 +81,6 @@ void main() {
return captured; return captured;
} }
List<String> captureOriginalFileNames() {
final captured = <String>[];
when(
() => mockUploadRepository.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((invocation) async {
captured.add(invocation.namedArguments[#originalFileName] as String);
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
});
return captured;
}
group('uploadSingleAsset', () { group('uploadSingleAsset', () {
test('should upload the motion part hidden and keep the still image visible', () async { test('should upload the motion part hidden and keep the still image visible', () async {
final asset = LocalAssetStub.image1; final asset = LocalAssetStub.image1;
@@ -142,59 +124,5 @@ void main() {
expect(captured, hasLength(1)); expect(captured, hasLength(1));
expect(captured[0].containsKey('visibility'), isFalse); expect(captured[0].containsKey('visibility'), isFalse);
}); });
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/IMG_6499.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['IMG_6499.jpg']));
});
test('keeps the .dng extension for a genuine RAW original', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/IMG_5210.dng');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['IMG_5210.dng']));
});
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/DJI_0001');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['DJI_0001.jpg']));
});
}); });
} }

View File

@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
enum _Fruit { apple, banana, cherry }
void main() {
group('MapCodec', () {
group('encode', () {
test('serializes an empty map to an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({}), '{}');
});
test('encodes a string-to-string map as a JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({'a': '1', 'b': '2'}), '{"a":"1","b":"2"}');
});
test('stringifies non-string values via the value codec', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.encode({'x': 10, 'y': 20}), '{"x":"10","y":"20"}');
});
test('stringifies non-string keys via the key codec', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.encode({1: 'one', 2: 'two'}), '{"1":"one","2":"two"}');
});
});
group('decode', () {
test('reconstructs a string-to-string map', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":"1","b":"2"}'), {'a': '1', 'b': '2'});
});
test('parses values back to their domain type', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":"10","y":"20"}'), {'x': 10, 'y': 20});
});
test('parses keys back to their domain type', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.decode('{"1":"one","2":"two"}'), {1: 'one', 2: 'two'});
});
test('returns an empty map for an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{}'), isEmpty);
});
test('returns an empty map when the payload is not valid JSON', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('not json'), isEmpty);
});
test('returns an empty map when the JSON root is not an object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('[]'), isEmpty);
expect(codec.decode('"a string"'), isEmpty);
expect(codec.decode('42'), isEmpty);
});
test('skips entries whose value is not a JSON string, keeping the rest', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":"20"}'), {'y': 20});
});
test('skips entries whose value is a nested object, keeping the rest', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":{"nested":"value"},"b":"ok"}'), {'b': 'ok'});
});
test('returns an empty map when every entry is malformed', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":2}'), isEmpty);
});
});
group('round trip', () {
test('preserves a primitive map through encode then decode', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
const original = {'one': 1, 'two': 2, 'three': 3};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves an enum-valued map by composing with EnumCodec', () {
const codec = MapCodec<String, _Fruit>(PrimitiveCodec.string, EnumCodec(_Fruit.values));
const original = {'breakfast': _Fruit.banana, 'snack': _Fruit.apple};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves a DateTime-valued map by composing with DateTimeCodec', () {
const codec = MapCodec<String, DateTime>(PrimitiveCodec.string, DateTimeCodec());
final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)};
expect(codec.decode(codec.encode(original)), original);
});
});
});
}

View File

@@ -47,7 +47,7 @@ program
.usage('[paths...] [options]') .usage('[paths...] [options]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS')) .addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('--skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false)) .addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
.addOption( .addOption(
new Option('-a, --album', 'Automatically create albums based on folder name') new Option('-a, --album', 'Automatically create albums based on folder name')

View File

@@ -308,76 +308,6 @@
}, },
"uiHints": ["Filter"] "uiHints": ["Filter"]
}, },
{
"name": "assetExifFilter",
"title": "Filter by EXIF metadata",
"description": "Filter assets by their EXIF properties",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"property": {
"title": "Property",
"description": "EXIF property to match",
"type": "string",
"enum": [
"make",
"model",
"exifImageWidth",
"exifImageHeight",
"fileSizeInByte",
"orientation",
"lensModel",
"fNumber",
"focalLength",
"iso",
"description",
"fps",
"exposureTime",
"livePhotoCID",
"timeZone",
"projectionType",
"profileDescription",
"colorspace",
"bitsPerSample",
"rating"
],
"uiHint": {
"order": 1
}
},
"pattern": {
"type": "string",
"title": "Pattern",
"description": "Text or regex pattern to match against property value",
"uiHint": {
"order": 2
}
},
"matchType": {
"type": "string",
"title": "Match type",
"enum": ["contains", "startsWith", "exact", "regex"],
"default": "contains",
"description": "Type of pattern matching to perform",
"uiHint": {
"order": 3
}
},
"caseSensitive": {
"type": "boolean",
"default": false,
"title": "Case sensitive",
"description": "Whether matching should be case-sensitive",
"uiHint": {
"order": 4
}
}
},
"required": ["property", "pattern"]
},
"uiHints": ["Filter"]
},
{ {
"name": "assetArchive", "name": "assetArchive",
"title": "Archive asset", "title": "Archive asset",

View File

@@ -2,42 +2,6 @@ import { wrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts'; import type { Manifest } from '../dist/index.d.ts';
type MatchValueConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
const matchValueResult = (value: string, config: MatchValueConfig) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const searchName = caseSensitive ? value : value.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(value) } };
}
default: {
return {};
}
}
};
const methods = wrapper<Manifest>({ const methods = wrapper<Manifest>({
assetAddToAlbums: ({ config, data, functions }) => { assetAddToAlbums: ({ config, data, functions }) => {
const assetId = data.asset.id; const assetId = data.asset.id;
@@ -89,7 +53,39 @@ const methods = wrapper<Manifest>({
} }
}, },
assetFileFilter: ({ data, config }) => matchValueResult(data.asset.originalFileName || '', config), assetFileFilter: ({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config;
const { asset } = data;
const fileName = usePath ? asset.originalPath : asset.originalFileName;
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
},
assetLocationFilter: ({ config, data }) => { assetLocationFilter: ({ config, data }) => {
if ( if (
@@ -128,14 +124,6 @@ const methods = wrapper<Manifest>({
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
}, },
assetExifFilter: ({ config, data }) => {
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
return { workflow: { continue: false } };
}
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
},
assetDateFilter: ({ config, data }) => { assetDateFilter: ({ config, data }) => {
const assetDate = new Date(data.asset.localDateTime); const assetDate = new Date(data.asset.localDateTime);
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day); let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
@@ -212,7 +200,6 @@ const {
assetFavorite, assetFavorite,
assetFileFilter, assetFileFilter,
assetLocationFilter, assetLocationFilter,
assetExifFilter,
assetDateFilter, assetDateFilter,
assetLock, assetLock,
assetMissingTimeZoneFilter, assetMissingTimeZoneFilter,
@@ -230,7 +217,6 @@ export {
assetFavorite, assetFavorite,
assetFileFilter, assetFileFilter,
assetLocationFilter, assetLocationFilter,
assetExifFilter,
assetDateFilter, assetDateFilter,
assetLock, assetLock,
assetMissingTimeZoneFilter, assetMissingTimeZoneFilter,

View File

@@ -8,13 +8,13 @@ import {
} from 'src/commands/media-location.command'; } from 'src/commands/media-location.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
import { SchemaCheck } from 'src/commands/schema-check'; import { SchemaCheck } from 'src/commands/schema-check';
import { VersionCommand } from 'src/commands/version.command'; import { VersionCommand } from 'src/commands/version.command';
export const commandsAndQuestions = [ export const commandsAndQuestions = [
ResetAdminPasswordCommand, ResetAdminPasswordCommand,
PromptPasswordResetQuestions, PromptPasswordQuestions,
PromptEmailQuestion, PromptEmailQuestion,
EnablePasswordLoginCommand, EnablePasswordLoginCommand,
DisablePasswordLoginCommand, DisablePasswordLoginCommand,

View File

@@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
const prompt = (inquirer: InquirerService) => { const prompt = (inquirer: InquirerService) => {
return (admin: UserAdminResponseDto) => { return function ask(admin: UserAdminResponseDto) {
const { id, oauthId, email, name } = admin; const { id, oauthId, email, name } = admin;
console.log(`Found Admin: console.log(`Found Admin:
- ID=${id} - ID=${id}
@@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => {
- Email=${email} - Email=${email}
- Name=${name}`); - Name=${name}`);
return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {}); return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password);
}; };
}; };
@@ -43,23 +43,13 @@ export class ResetAdminPasswordCommand extends CommandRunner {
} }
} }
@QuestionSet({ name: 'prompt-password-reset' }) @QuestionSet({ name: 'prompt-password' })
export class PromptPasswordResetQuestions { export class PromptPasswordQuestions {
@Question({ @Question({
message: 'Please choose a new password (optional)', message: 'Please choose a new password (optional)',
name: 'newPassword', name: 'password',
}) })
parsePassword(value: string) { parsePassword(value: string) {
return value; return value;
} }
@Question({
type: 'confirm',
message: 'Invalidate existing sessions?',
default: true,
name: 'invalidateSessions',
})
parseInvalidate(value: boolean): boolean {
return value;
}
} }

View File

@@ -568,34 +568,6 @@ describe(AssetService.name, () => {
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId); expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
}); });
it('should delete the stack when a non-primary asset is deleted and only the primary would remain', async () => {
const asset = AssetFactory.from().build();
const deletionAsset = {
...getForAssetDeletion(asset),
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }] },
};
mocks.stack.delete.mockResolvedValue();
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.stack.delete).toHaveBeenCalledWith(deletionAsset.stack.id);
});
it('should keep the stack when a non-primary asset is deleted and the primary plus another asset remain', async () => {
const asset = AssetFactory.from().build();
const deletionAsset = {
...getForAssetDeletion(asset),
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }, { id: newUuid() }] },
};
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.stack.delete).not.toHaveBeenCalled();
expect(mocks.stack.update).not.toHaveBeenCalled();
});
it('should delete a live photo', async () => { it('should delete a live photo', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build(); const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });

View File

@@ -316,25 +316,18 @@ export class AssetService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
if (asset.stack) { // replace the parent of the stack children with a new asset
// asset.stack.assets only includes timeline visible assets and excludes the primary asset if (asset.stack?.primaryAssetId === id) {
const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id); // this only includes timeline visible assets and excludes the primary asset
const stackAssetIds = asset.stack.assets.map((a) => a.id);
// the primary survives unless it is the asset being deleted if (stackAssetIds.length >= 2) {
let remainingCount = remainingStackAssetIds.length; const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
if (asset.stack.primaryAssetId !== id) {
remainingCount++;
}
if (remainingCount < 2) {
// 0 or 1 asset would remain: dissolve the stack so it does not linger as a single-asset stack
await this.stackRepository.delete(asset.stack.id);
} else if (asset.stack.primaryAssetId === id) {
// the primary is being deleted but others remain: promote a new primary
await this.stackRepository.update(asset.stack.id, { await this.stackRepository.update(asset.stack.id, {
id: asset.stack.id, id: asset.stack.id,
primaryAssetId: remainingStackAssetIds[0], primaryAssetId: newPrimaryAssetId,
}); });
} else {
await this.stackRepository.delete(asset.stack.id);
} }
} }

View File

@@ -37,7 +37,7 @@ describe(CliService.name, () => {
mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false }); const ask = vitest.fn().mockImplementation(() => {});
const response = await sut.resetAdminPassword(ask); const response = await sut.resetAdminPassword(ask);
@@ -47,7 +47,6 @@ describe(CliService.name, () => {
expect(ask).toHaveBeenCalled(); expect(ask).toHaveBeenCalled();
expect(id).toEqual(admin.id); expect(id).toEqual(admin.id);
expect(update.password).toBeDefined(); expect(update.password).toBeDefined();
expect(mocks.session.invalidateAll).not.toHaveBeenCalled();
}); });
it('should use the supplied password', async () => { it('should use the supplied password', async () => {
@@ -56,7 +55,7 @@ describe(CliService.name, () => {
mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin);
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false }); const ask = vitest.fn().mockResolvedValue('new-password');
const response = await sut.resetAdminPassword(ask); const response = await sut.resetAdminPassword(ask);
@@ -67,20 +66,6 @@ describe(CliService.name, () => {
expect(id).toEqual(admin.id); expect(id).toEqual(admin.id);
expect(update.password).toBeDefined(); expect(update.password).toBeDefined();
}); });
it('should invalidate existing sessions when requested', async () => {
const admin = UserFactory.create({ isAdmin: true });
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(admin);
mocks.session.invalidateAll.mockResolvedValue(void 0);
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true });
await sut.resetAdminPassword(ask);
expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id });
});
}); });
describe('disablePasswordLogin', () => { describe('disablePasswordLogin', () => {

View File

@@ -58,24 +58,18 @@ export class CliService extends BaseService {
return users.map((user) => mapUserAdmin(user)); return users.map((user) => mapUserAdmin(user));
} }
async resetAdminPassword( async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>,
) {
const admin = await this.userRepository.getAdmin(); const admin = await this.userRepository.getAdmin();
if (!admin) { if (!admin) {
throw new Error('Admin account does not exist'); throw new Error('Admin account does not exist');
} }
const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin)); const providedPassword = await ask(mapUserAdmin(admin));
const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
await this.userRepository.update(admin.id, { password: hashedPassword }); await this.userRepository.update(admin.id, { password: hashedPassword });
if (invalidateSessions) {
await this.sessionRepository.invalidateAll({ userId: admin.id });
}
return { admin, password, provided: !!providedPassword }; return { admin, password, provided: !!providedPassword };
} }

View File

@@ -433,10 +433,9 @@ describe('core plugin', () => {
describe('assetDateFilter', () => { describe('assetDateFilter', () => {
it('should favorite assets created during the first 7 days of a specific year and month', async () => { it('should favorite assets created during the first 7 days of a specific year and month', async () => {
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([ const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-01') }), ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-01') }),
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-07T23:59:59Z') }), ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-07T23:59:59Z') }),
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
]); ]);
const workflow = await createWorkflow({ const workflow = await createWorkflow({
@@ -462,46 +461,6 @@ describe('core plugin', () => {
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }); await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true }); await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
});
it('should match recurring dates regardless of the year', async () => {
const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2026-03-01') }),
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('1998-12-21') }),
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
]);
await ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2010-06-15') });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#assetDateFilter',
config: {
startDate: { day: 12, month: 12, year: 2000 },
endDate: { day: 30, month: 3, year: 2001 },
recurring: true,
},
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id });
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
}); });
}); });

View File

@@ -55,36 +55,34 @@
{/each} {/each}
</div> --> </div> -->
<div class="overflow-x-auto pb-1"> <div class="grid grid-flow-col grid-rows-7 gap-0.5">
<div class="grid grid-flow-col grid-rows-7 gap-0.5"> <div class="row-span-7 grid grid-rows-subgrid">
<div class="row-span-7 grid grid-rows-subgrid"> {#if Info.getStartOfWeek({ locale: $locale }) === 7}
{#if Info.getStartOfWeek({ locale: $locale }) === 7} <div></div>
<div></div> {/if}
{/if} <div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[0]}</Text></div>
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[0]}</Text></div> <div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[1]}</Text></div>
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[1]}</Text></div> <div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[2]}</Text></div>
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[2]}</Text></div> {#if Info.getStartOfWeek({ locale: $locale }) === 1}
{#if Info.getStartOfWeek({ locale: $locale }) === 1} <div class="-my-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[3]}</Text></div>
<div class="-my-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[3]}</Text></div> {/if}
{/if}
</div>
{#each data.series as day, idx (day.date)}
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
{ month: 'short', day: 'numeric' },
{ locale: $locale },
)}
<div
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
style:--heatmap-row-start={idx === 0 ? padding + 1 : undefined}
title={itemLabel({ date, count: day.count })}
aria-label={itemLabel({ date, count: day.count })}
></div>
{/each}
</div> </div>
{#each data.series as day, idx (day.date)}
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
{ month: 'short', day: 'numeric' },
{ locale: $locale },
)}
<div
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
style:--heatmap-row-start={idx === 0 ? padding + 1 : undefined}
title={itemLabel({ date, count: day.count })}
aria-label={itemLabel({ date, count: day.count })}
></div>
{/each}
</div> </div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{$t('less')}</span> <span>{$t('less')}</span>
<span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span> <span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span>
<span class="size-3 rounded-sm bg-immich-primary/30"></span> <span class="size-3 rounded-sm bg-immich-primary/30"></span>

View File

@@ -1,6 +1,6 @@
import { AssetOrder, AssetOrderBy, type AssetResponseDto } from '@immich/sdk'; import { AssetOrder, type AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getOrderingDate, plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { TimelineManager } from '../timeline-manager.svelte'; import { TimelineManager } from '../timeline-manager.svelte';
import type { TimelineMonth } from '../timeline-month.svelte'; import type { TimelineMonth } from '../timeline-month.svelte';
import type { AssetDescriptor, Direction, TimelineAsset } from '../types'; import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
@@ -125,14 +125,7 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
return []; return [];
} }
const assetOrder: AssetOrder = timelineManager.getAssetOrder(); const assetOrder: AssetOrder = timelineManager.getAssetOrder();
const orderBy: AssetOrderBy = timelineManager.getOrderBy(); if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
if (
plainDateTimeCompare(
assetOrder === AssetOrder.Desc,
getOrderingDate(startAsset, orderBy),
getOrderingDate(endAsset, orderBy),
) < 0
) {
[startAsset, endAsset] = [endAsset, startAsset]; [startAsset, endAsset] = [endAsset, startAsset];
// eslint-disable-next-line no-useless-assignment // eslint-disable-next-line no-useless-assignment
[startTimelineMonth, endTimelineMonth] = [endTimelineMonth, startTimelineMonth]; [startTimelineMonth, endTimelineMonth] = [endTimelineMonth, startTimelineMonth];

View File

@@ -1,4 +1,4 @@
import { AssetOrderBy, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
@@ -808,44 +808,4 @@ describe('TimelineManager', () => {
expect(b.showAssetOwners).toBe(true); expect(b.showAssetOwners).toBe(true);
}); });
}); });
describe('retrieveRange', () => {
it('uses createdAt ordering in the Recently Added view (orderBy=CreatedAt)', async () => {
// Simulate the "Recently Added" bug: two assets whose localDateTime order is
// the reverse of their createdAt (upload) order. Before the fix, retrieveRange
// compared localDateTime and selected the wrong range direction.
const timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateOptions({ orderBy: AssetOrderBy.CreatedAt });
// assetA was taken recently (2024) but uploaded first (2025-01)
const assetA = timelineAssetFactory.build({
localDateTime: fromISODateTimeUTCToObject('2024-06-01T00:00:00.000Z'),
createdAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'),
fileCreatedAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'),
});
// assetB was taken long ago (2018) but uploaded second (2025-02)
const assetB = timelineAssetFactory.build({
localDateTime: fromISODateTimeUTCToObject('2018-03-15T00:00:00.000Z'),
createdAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'),
fileCreatedAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'),
});
// assetC is an unrelated asset that should not appear in the selection
const assetC = timelineAssetFactory.build({
localDateTime: fromISODateTimeUTCToObject('2022-01-01T00:00:00.000Z'),
createdAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'),
fileCreatedAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'),
});
timelineManager.upsertAssets([assetA, assetB, assetC]);
// Shift-click from assetB (uploaded most recently) to assetA — the range
// between them in the "Recently Added" timeline should contain only those two.
const range = await timelineManager.retrieveRange({ id: assetB.id }, { id: assetA.id });
const ids = range.map((a) => a.id);
expect(ids).toContain(assetA.id);
expect(ids).toContain(assetB.id);
expect(ids).not.toContain(assetC.id);
});
});
}); });

View File

@@ -625,10 +625,6 @@ export class TimelineManager extends VirtualScrollManager {
return this.#options.order ?? AssetOrder.Desc; return this.#options.order ?? AssetOrder.Desc;
} }
getOrderBy() {
return this.#options.orderBy ?? AssetOrderBy.TakenAt;
}
protected postCreateSegments(): void { protected postCreateSegments(): void {
this.months.sort((a, b) => { this.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year return a.yearMonth.year === b.yearMonth.year

View File

@@ -213,7 +213,7 @@
</Stack> </Stack>
</AdminCard> </AdminCard>
<div class="col-span-full px-4 py-2"> <div class="col-span-2 px-4 py-2">
<div class="flex gap-2 text-primary"> <div class="flex gap-2 text-primary">
<Icon icon={mdiCloudUploadOutline} size="1.5rem" /> <Icon icon={mdiCloudUploadOutline} size="1.5rem" />
<CardTitle>{$t('uploads')}</CardTitle> <CardTitle>{$t('uploads')}</CardTitle>