diff --git a/deployment/mise.toml b/deployment/mise.toml index f3d07ac31f..53b683a7d3 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,6 +1,6 @@ [tools] -terragrunt = "0.91.2" -opentofu = "1.10.6" +terragrunt = "0.93.10" +opentofu = "1.10.7" [tasks."tg:fmt"] run = "terragrunt hclfmt" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index e2fb8fbc30..6fa1c51bdd 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -135,7 +135,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa + image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 90dc00d942..1aae886bc6 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa + image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e4e0f964d3..51b6d3e860 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa + image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index e3df672d35..9dcfcac48b 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc., the job may not have run automatically the first time. -### How can I hide photos from the timeline? +### How can I hide a photo or video from the timeline? -You can _archive_ them. +You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view ### How can I backup data from Immich? diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index e1b96f9164..d7c9edcd31 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -18,6 +18,7 @@ make e2e Before you can run the tests, you need to run the following commands _once_: - `pnpm install` (in `e2e/`) +- `pnpm run build` (in `cli/`) - `make open-api` (in the project root `/`) Once the test environment is running, the e2e tests can be run via: diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 8863a13ee7..76784b285a 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work ## Ports -| Variable | Description | Default | -| :------------ | :------------- | :----------------------------------------: | -| `IMMICH_HOST` | Listening host | `0.0.0.0` | -| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | +| Variable | Description | Default | Containers | +| :------------ | :------------- | :----------------------------------------: | :----------------------- | +| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning | +| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning | ## Database @@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work | `DB_SSL_MODE` | Database SSL mode | | server | | `DB_VECTOR_EXTENSION`\*2 | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server | | `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | -| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])\*3 | `SSD` | server | +| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])\*3 | `SSD` | database | \*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`. diff --git a/i18n/en.json b/i18n/en.json index 42965e06a8..ef1bbc76d6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2192,6 +2192,7 @@ "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", + "view_asset_owners": "View asset owners", "view_details": "View Details", "view_in_timeline": "View in timeline", "view_link": "View link", diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index 4101a5c6f7..d34a51684e 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -82,6 +82,7 @@ class TextDetector(InferenceModel): ratio = float(self.max_resolution) / img.height else: ratio = float(self.max_resolution) / img.width + ratio = min(ratio, 1.0) resize_h = int(img.height * ratio) resize_w = int(img.width * ratio) diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index ca21ffe389..356e954ef4 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -2206,7 +2206,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.4" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2214,9 +2214,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] diff --git a/mise.toml b/mise.toml index d24893575a..5f3aef61de 100644 --- a/mise.toml +++ b/mise.toml @@ -4,8 +4,8 @@ experimental_monorepo_root = true node = "24.11.1" flutter = "3.35.7" pnpm = "10.22.0" -terragrunt = "0.91.2" -opentofu = "1.10.6" +terragrunt = "0.93.10" +opentofu = "1.10.7" java = "25.0.1" [tools."github:CQLabs/homebrew-dcm"] diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart new file mode 100644 index 0000000000..b3ab756414 --- /dev/null +++ b/mobile/lib/domain/models/events.model.dart @@ -0,0 +1,32 @@ +import 'package:immich_mobile/domain/utils/event_stream.dart'; + +// Timeline Events +class TimelineReloadEvent extends Event { + const TimelineReloadEvent(); +} + +class ScrollToTopEvent extends Event { + const ScrollToTopEvent(); +} + +class ScrollToDateEvent extends Event { + final DateTime date; + + const ScrollToDateEvent(this.date); +} + +// Asset Viewer Events +class ViewerOpenBottomSheetEvent extends Event { + final bool activitiesMode; + const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); +} + +class ViewerReloadAssetEvent extends Event { + const ViewerReloadAssetEvent(); +} + +// Multi-Select Events +class MultiSelectToggleEvent extends Event { + final bool isEnabled; + const MultiSelectToggleEvent(this.isEnabled); +} diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index d8404db409..a18644cd2a 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -71,6 +71,7 @@ enum StoreKey { readonlyModeEnabled._(138), autoPlayVideo._(139), + albumGridView._(140), // Experimental stuff photoManagerCustomFilter._(1000), diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart index d4cc5ab5c6..c531fa4a94 100644 --- a/mobile/lib/domain/models/timeline.model.dart +++ b/mobile/lib/domain/models/timeline.model.dart @@ -1,5 +1,3 @@ -import 'package:immich_mobile/domain/utils/event_stream.dart'; - enum GroupAssetsBy { day, month, auto, none } enum HeaderType { none, month, day, monthAndDay } @@ -31,17 +29,3 @@ class TimeBucket extends Bucket { @override int get hashCode => super.hashCode ^ date.hashCode; } - -class TimelineReloadEvent extends Event { - const TimelineReloadEvent(); -} - -class ScrollToTopEvent extends Event { - const ScrollToTopEvent(); -} - -class ScrollToDateEvent extends Event { - final DateTime date; - - const ScrollToDateEvent(this.date); -} diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 67e91188e2..68c72255b0 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; class RemoteAlbumService { final DriftRemoteAlbumRepository _repository; @@ -32,16 +33,16 @@ class RemoteAlbumService { Future> sortAlbums( List albums, - RemoteAlbumSortMode sortMode, { + AlbumSortMode sortMode, { bool isReverse = false, }) async { final List sorted = switch (sortMode) { - RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt), - RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name), - RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), - RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), - RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums), - RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums), + AlbumSortMode.created => albums.sortedBy((album) => album.createdAt), + AlbumSortMode.title => albums.sortedBy((album) => album.name), + AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), + AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), + AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), + AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), }; return (isReverse ? sorted.reversed : sorted).toList(); @@ -211,16 +212,3 @@ class RemoteAlbumService { return sorted.reversed.toList(); } } - -enum RemoteAlbumSortMode { - title("library_page_sort_title"), - assetCount("library_page_sort_asset_count"), - lastModified("library_page_sort_last_modified"), - created("library_page_sort_created"), - mostRecent("sort_newest"), - mostOldest("sort_oldest"); - - final String key; - - const RemoteAlbumSortMode(this.key); -} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 9537fe667a..96630f1eba 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart index 22d5d5030a..a8ca7ef2aa 100644 --- a/mobile/lib/extensions/asset_extensions.dart +++ b/mobile/lib/extensions/asset_extensions.dart @@ -1,5 +1,5 @@ import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:timezone/timezone.dart'; +import 'package:immich_mobile/utils/timezone.dart'; extension TZExtension on Asset { /// Returns the created time of the asset from the exif info (if available) or from @@ -7,24 +7,11 @@ extension TZExtension on Asset { /// the timezone offset in [Duration] (DateTime, Duration) getTZAdjustedTimeAndOffset() { DateTime dt = fileCreatedAt.toLocal(); + if (exifInfo?.dateTimeOriginal != null) { - dt = exifInfo!.dateTimeOriginal!; - if (exifInfo?.timeZone != null) { - dt = dt.toUtc(); - try { - final location = getLocation(exifInfo!.timeZone!); - dt = TZDateTime.from(dt, location); - } on LocationNotFoundException { - RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); - final m = re.firstMatch(exifInfo!.timeZone!); - if (m != null) { - final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); - dt = dt.add(duration); - return (dt, duration); - } - } - } + return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone); } + return (dt, dt.timeZoneOffset); } } diff --git a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart index f3fdccc329..36d51c5624 100644 --- a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart +++ b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart @@ -3,7 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 0fe2ccec09..86c80253dc 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget { context.locale; return Scaffold( appBar: AppBar(centerTitle: false, title: const Text('settings').tr()), - body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()), + body: context.isMobile ? const _MobileLayout() : const _TabletLayout(), ); } } @@ -89,11 +89,7 @@ class _MobileLayout extends StatelessWidget { ], ) .toList(); - return ListView( - physics: const ClampingScrollPhysics(), - padding: const EdgeInsets.only(top: 10.0, bottom: 16), - children: [...settings], - ); + return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]); } } diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index bbb567bd3b..2fdcec4054 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; @@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; -import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @RoutePage() diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index 290a19f584..4ba877bcba 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 723700af55..8b82e5c839 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart index 3cd939aeb6..5d8ea8671c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 4979df904c..a0191e2407 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index ddc83cb383..20d391c4a6 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart index df8f544601..a78ff2ccd8 100644 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index 8b04a1b05d..32147a194f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -9,8 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; // used to allow performing unarchive action from different sources (without duplicating code) Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 0d5b9a7636..4110966e57 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/services/remote_album.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -17,6 +16,9 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart' import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; @@ -45,14 +47,28 @@ class _AlbumSelectorState extends ConsumerState { List shownAlbums = []; AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all); - AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true); + AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true); @override void initState() { super.initState(); - // Load albums when component mounts WidgetsBinding.instance.addPostFrameCallback((_) { + final appSettings = ref.read(appSettingsServiceProvider); + final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder); + final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse); + final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView); + + final albumSortMode = AlbumSortMode.values.firstWhere( + (e) => e.storeIndex == savedSortMode, + orElse: () => AlbumSortMode.lastModified, + ); + + setState(() { + sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse); + isGrid = savedIsGrid; + }); + ref.read(remoteAlbumProvider.notifier).refresh(); }); @@ -82,6 +98,7 @@ class _AlbumSelectorState extends ConsumerState { setState(() { isGrid = !isGrid; }); + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid); } void changeFilter(QuickFilterMode mode) { @@ -97,6 +114,10 @@ class _AlbumSelectorState extends ConsumerState { this.sort = sort; }); + final appSettings = ref.read(appSettingsServiceProvider); + await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex); + await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse); + await sortAlbums(); } @@ -181,6 +202,8 @@ class _AlbumSelectorState extends ConsumerState { onToggleViewMode: toggleViewMode, onSortChanged: changeSort, controller: menuController, + currentSortMode: sort.mode, + currentIsReverse: sort.isReverse, ), isGrid ? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected) @@ -192,21 +215,46 @@ class _AlbumSelectorState extends ConsumerState { } class _SortButton extends ConsumerStatefulWidget { - const _SortButton(this.onSortChanged, {this.controller}); + const _SortButton( + this.onSortChanged, { + required this.initialSortMode, + required this.initialIsReverse, + this.controller, + }); final Future Function(AlbumSort) onSortChanged; final MenuController? controller; + final AlbumSortMode initialSortMode; + final bool initialIsReverse; @override ConsumerState<_SortButton> createState() => _SortButtonState(); } class _SortButtonState extends ConsumerState<_SortButton> { - RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; - bool albumSortIsReverse = true; + late AlbumSortMode albumSortOption; + late bool albumSortIsReverse; bool isSorting = false; - Future onMenuTapped(RemoteAlbumSortMode sortMode) async { + @override + void initState() { + super.initState(); + albumSortOption = widget.initialSortMode; + albumSortIsReverse = widget.initialIsReverse; + } + + @override + void didUpdateWidget(_SortButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) { + setState(() { + albumSortOption = widget.initialSortMode; + albumSortIsReverse = widget.initialIsReverse; + }); + } + } + + Future onMenuTapped(AlbumSortMode sortMode) async { final selected = albumSortOption == sortMode; // Switch direction if (selected) { @@ -240,7 +288,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { padding: const WidgetStatePropertyAll(EdgeInsets.all(4)), ), consumeOutsideTap: true, - menuChildren: RemoteAlbumSortMode.values + menuChildren: AlbumSortMode.values .map( (sortMode) => MenuItemButton( leadingIcon: albumSortOption == sortMode @@ -269,7 +317,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { ), ), child: Text( - sortMode.key.t(context: context), + sortMode.label.t(context: context), style: context.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: albumSortOption == sortMode @@ -298,7 +346,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { : const Icon(Icons.keyboard_arrow_up_rounded), ), Text( - albumSortOption.key.t(context: context), + albumSortOption.label.t(context: context), style: context.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, color: context.colorScheme.onSurface.withAlpha(225), @@ -465,6 +513,8 @@ class _QuickSortAndViewMode extends StatelessWidget { required this.isGrid, required this.onToggleViewMode, required this.onSortChanged, + required this.currentSortMode, + required this.currentIsReverse, this.controller, }); @@ -472,6 +522,8 @@ class _QuickSortAndViewMode extends StatelessWidget { final VoidCallback onToggleViewMode; final MenuController? controller; final Future Function(AlbumSort) onSortChanged; + final AlbumSortMode currentSortMode; + final bool currentIsReverse; @override Widget build(BuildContext context) { @@ -481,7 +533,12 @@ class _QuickSortAndViewMode extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _SortButton(onSortChanged, controller: controller), + _SortButton( + onSortChanged, + controller: controller, + initialSortMode: currentSortMode, + initialIsReverse: currentIsReverse, + ), IconButton( icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24), onPressed: onToggleViewMode, diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 50c4347301..70eb6699aa 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -7,7 +7,7 @@ import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index d0fb1f8ba0..36e5bf67d9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -1,17 +1,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -class ViewerOpenBottomSheetEvent extends Event { - final bool activitiesMode; - const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); -} - -class ViewerReloadAssetEvent extends Event { - const ViewerReloadAssetEvent(); -} - class AssetViewerState { final int backgroundOpacity; final bool showingBottomSheet; diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 582a33136a..276034d3d6 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -29,6 +30,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; const _kSeparator = ' • '; @@ -85,13 +87,21 @@ class AssetDetailBottomSheet extends ConsumerWidget { class _AssetDetailBottomSheet extends ConsumerWidget { const _AssetDetailBottomSheet(); - String _getDateTime(BuildContext ctx, BaseAsset asset) { - final dateTime = asset.createdAt.toLocal(); + String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + // Use EXIF timezone information if available (matching web app behavior) + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - final timezone = dateTime.timeZoneOffset.isNegative - ? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}' - : 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'; + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; return '$date$_kSeparator$time $timezone'; } @@ -269,7 +279,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { children: [ // Asset Date and Time SheetTile( - title: _getDateTime(context, asset), + title: _getDateTime(context, asset, exifInfo), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index ab88dffab4..5114ef6fd2 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart index 8d374f74ff..ae4cfbd1c6 100644 --- a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -143,11 +143,13 @@ class BackupToggleButtonState extends ConsumerState with Sin Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - "enable_backup".t(context: context), - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, + Flexible( + child: Text( + "enable_backup".t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), ), ), ], diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart index f067bc6bf3..b514f9f0a5 100644 --- a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart @@ -3,8 +3,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 70dd15bf7f..5868de92aa 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 38ba52dc56..e3cffeb093 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -70,7 +71,7 @@ class RemoteAlbumNotifier extends Notifier { Future> sortAlbums( List albums, - RemoteAlbumSortMode sortMode, { + AlbumSortMode sortMode, { bool isReverse = false, }) async { return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse); diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 6949413cd9..0b3f7e610b 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -2,7 +2,6 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; final multiSelectProvider = NotifierProvider( @@ -10,11 +9,6 @@ final multiSelectProvider = NotifierProvider selectedAssets; final Set lockedSelectionAssets; diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index abe7ac3fa2..30f43cf3b2 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -245,23 +245,15 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], transitionsBuilder: TransitionsBuilders.slideLeft, ), - CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn), + AutoRoute(page: FolderRoute.page, guards: [_authGuard]), AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), - CustomRoute( - page: TrashRoute.page, - guards: [_authGuard, _duplicateGuard], - transitionsBuilder: TransitionsBuilders.slideLeft, - ), - CustomRoute( - page: SharedLinkRoute.page, - guards: [_authGuard, _duplicateGuard], - transitionsBuilder: TransitionsBuilders.slideLeft, - ), + AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]), CustomRoute( page: ActivitiesRoute.page, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 59b627ecc3..4261613a19 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; @@ -175,9 +176,17 @@ class ActionService { } final exifData = await _remoteAssetRepository.getExif(assetId); - initialDate = asset.createdAt.toLocal(); - offset = initialDate.timeZoneOffset; - timeZone = exifData?.timeZone; + + // Use EXIF timezone information if available (matching web app and display behavior) + DateTime dt = asset.createdAt.toLocal(); + offset = dt.timeZoneOffset; + + if (exifData?.dateTimeOriginal != null) { + timeZone = exifData!.timeZone; + (dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone); + } + + initialDate = dt; } final dateTime = await showDateTimePicker( diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 7149408e8a..fc08193d11 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -51,9 +51,10 @@ enum AppSettingsEnum { enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), + readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), + albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), - readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/utils/album_filter.utils.dart b/mobile/lib/utils/album_filter.utils.dart index 02142b1571..8f9363d4d9 100644 --- a/mobile/lib/utils/album_filter.utils.dart +++ b/mobile/lib/utils/album_filter.utils.dart @@ -1,5 +1,5 @@ -import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; class AlbumFilter { String? userId; @@ -14,12 +14,12 @@ class AlbumFilter { } class AlbumSort { - RemoteAlbumSortMode mode; + AlbumSortMode mode; bool isReverse; AlbumSort({required this.mode, this.isReverse = false}); - AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) { + AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) { return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse); } } diff --git a/mobile/lib/utils/timezone.dart b/mobile/lib/utils/timezone.dart new file mode 100644 index 0000000000..d75122062f --- /dev/null +++ b/mobile/lib/utils/timezone.dart @@ -0,0 +1,35 @@ +import 'package:timezone/timezone.dart'; + +/// Applies timezone conversion to a DateTime using EXIF timezone information. +/// +/// This function handles two timezone formats: +/// 1. Named timezone locations (e.g., "Asia/Hong_Kong") +/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00") +/// +/// Returns a tuple of (adjusted DateTime, timezone offset Duration) +(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) { + DateTime dt = dateTime.toUtc(); + + if (timeZone == null) { + return (dt, dt.timeZoneOffset); + } + + try { + // Try to get timezone location from database + final location = getLocation(timeZone); + dt = TZDateTime.from(dt, location); + return (dt, dt.timeZoneOffset); + } on LocationNotFoundException { + // Handle UTC offset format (e.g., "UTC+08:00") + RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); + final m = re.firstMatch(timeZone); + if (m != null) { + final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); + dt = dt.add(duration); + return (dt, duration); + } + } + + // If timezone is invalid, return UTC + return (dt, dt.timeZoneOffset); +} diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index c6a557964d..53fc32ddb3 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -193,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { InkWell( onTap: () { context.pop(); - launchUrl(Uri.parse('https://immich.app'), mode: LaunchMode.externalApplication); + launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication); }, child: Text("documentation", style: context.textTheme.bodySmall).tr(), ), diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 73dbbfc85b..44b547a5f1 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 0f9555a101..d5a7ea7cd9 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -6,8 +6,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/person.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/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index c0661bad48..c486d473b0 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index 0296a6bd99..64c3d9b832 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -108,82 +108,80 @@ class SyncStatusAndActions extends HookConsumerWidget { ); } - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 32), - child: ListView( - children: [ - const _SyncStatsCounts(), - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 24), - _SectionHeaderText(text: "jobs".t(context: context)), - ListTile( - title: Text( - "sync_local".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("tap_to_run_job".t(context: context)), - leading: const Icon(Icons.sync), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), - onTap: () { - ref.read(backgroundSyncProvider).syncLocal(full: true); - }, + return ListView( + padding: const EdgeInsets.only(top: 16, bottom: 96), + children: [ + const _SyncStatsCounts(), + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 24), + _SectionHeaderText(text: "jobs".t(context: context)), + ListTile( + title: Text( + "sync_local".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "sync_remote".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("tap_to_run_job".t(context: context)), - leading: const Icon(Icons.cloud_sync), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), - onTap: () { - ref.read(backgroundSyncProvider).syncRemote(); - }, + subtitle: Text("tap_to_run_job".t(context: context)), + leading: const Icon(Icons.sync), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), + onTap: () { + ref.read(backgroundSyncProvider).syncLocal(full: true); + }, + ), + ListTile( + title: Text( + "sync_remote".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "hash_asset".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - leading: const Icon(Icons.tag), - subtitle: Text("tap_to_run_job".t(context: context)), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), - onTap: () { - ref.read(backgroundSyncProvider).hashAssets(); - }, + subtitle: Text("tap_to_run_job".t(context: context)), + leading: const Icon(Icons.cloud_sync), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), + onTap: () { + ref.read(backgroundSyncProvider).syncRemote(); + }, + ), + ListTile( + title: Text( + "hash_asset".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 24), - _SectionHeaderText(text: "actions".t(context: context)), - ListTile( - title: Text( - "clear_file_cache".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - leading: const Icon(Icons.playlist_remove_rounded), - onTap: clearFileCache, + leading: const Icon(Icons.tag), + subtitle: Text("tap_to_run_job".t(context: context)), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), + onTap: () { + ref.read(backgroundSyncProvider).hashAssets(); + }, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 24), + _SectionHeaderText(text: "actions".t(context: context)), + ListTile( + title: Text( + "clear_file_cache".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "export_database".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("export_database_description".t(context: context)), - leading: const Icon(Icons.download), - onTap: exportDatabase, + leading: const Icon(Icons.playlist_remove_rounded), + onTap: clearFileCache, + ), + ListTile( + title: Text( + "export_database".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "reset_sqlite".t(context: context), - style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), - ), - leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), - onTap: () async { - await resetSqliteDb(context); - }, + subtitle: Text("export_database_description".t(context: context)), + leading: const Icon(Icons.download), + onTap: exportDatabase, + ), + ListTile( + title: Text( + "reset_sqlite".t(context: context), + style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), ), - ], - ), + leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), + onTap: () async { + await resetSqliteDb(context); + }, + ), + ], ); } } diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 426ea5ac0f..272b83c9aa 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -86,7 +86,6 @@ class NetworkingSettings extends HookConsumerWidget { return ListView( padding: const EdgeInsets.only(bottom: 96), - physics: const ClampingScrollPhysics(), children: [ Padding( padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), diff --git a/mobile/test/domain/services/album.service_test.dart b/mobile/test/domain/services/album.service_test.dart index ebd94a9450..b86819536d 100644 --- a/mobile/test/domain/services/album.service_test.dart +++ b/mobile/test/domain/services/album.service_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:mocktail/mocktail.dart'; @@ -76,42 +77,42 @@ void main() { test('should sort correctly based on name', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.title); + final result = await sut.sortAlbums(albums, AlbumSortMode.title); expect(result, [albumA, albumB]); }); test('should sort correctly based on createdAt', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.created); + final result = await sut.sortAlbums(albums, AlbumSortMode.created); expect(result, [albumA, albumB]); }); test('should sort correctly based on updatedAt', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.lastModified); + final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified); expect(result, [albumA, albumB]); }); test('should sort correctly based on assetCount', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.assetCount); + final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount); expect(result, [albumA, albumB]); }); test('should sort correctly based on newestAssetTimestamp', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostRecent); + final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent); expect(result, [albumA, albumB]); }); test('should sort correctly based on oldestAssetTimestamp', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostOldest); + final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest); expect(result, [albumB, albumA]); }); }); diff --git a/mobile/test/utils/timezone_test.dart b/mobile/test/utils/timezone_test.dart new file mode 100644 index 0000000000..d1e89dc473 --- /dev/null +++ b/mobile/test/utils/timezone_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:timezone/data/latest.dart' as tz; + +void main() { + setUpAll(() { + tz.initializeTimeZones(); + }); + + group('applyTimezoneOffset', () { + group('with named timezone locations', () { + test('should convert UTC to Asia/Hong_Kong (+08:00)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Hong_Kong', + ); + + expect(adjustedTime.hour, 20); // 12:00 UTC + 8 hours = 20:00 + expect(offset, const Duration(hours: 8)); + }); + + test('should convert UTC to America/New_York (handles DST)', () { + // Summer time (EDT = UTC-4) + final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0); + final (summerTime, summerOffset) = applyTimezoneOffset( + dateTime: summerUtc, + timeZone: 'America/New_York', + ); + + expect(summerTime.hour, 8); // 12:00 UTC - 4 hours = 08:00 + expect(summerOffset, const Duration(hours: -4)); + + // Winter time (EST = UTC-5) + final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0); + final (winterTime, winterOffset) = applyTimezoneOffset( + dateTime: winterUtc, + timeZone: 'America/New_York', + ); + + expect(winterTime.hour, 7); // 12:00 UTC - 5 hours = 07:00 + expect(winterOffset, const Duration(hours: -5)); + }); + + test('should convert UTC to Europe/London', () { + // Winter (GMT = UTC+0) + final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0); + final (winterTime, winterOffset) = applyTimezoneOffset( + dateTime: winterUtc, + timeZone: 'Europe/London', + ); + + expect(winterTime.hour, 12); + expect(winterOffset, Duration.zero); + + // Summer (BST = UTC+1) + final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0); + final (summerTime, summerOffset) = applyTimezoneOffset( + dateTime: summerUtc, + timeZone: 'Europe/London', + ); + + expect(summerTime.hour, 13); + expect(summerOffset, const Duration(hours: 1)); + }); + + test('should handle timezone with 30-minute offset (Asia/Kolkata)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Kolkata', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 30); // 12:00 UTC + 5:30 = 17:30 + expect(offset, const Duration(hours: 5, minutes: 30)); + }); + + test('should handle timezone with 45-minute offset (Asia/Kathmandu)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Kathmandu', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 45); // 12:00 UTC + 5:45 = 17:45 + expect(offset, const Duration(hours: 5, minutes: 45)); + }); + }); + + group('with UTC offset format', () { + test('should handle UTC+08:00 format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+08:00', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC-05:00 format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC-05:00', + ); + + expect(adjustedTime.hour, 7); + expect(offset, const Duration(hours: -5)); + }); + + test('should handle UTC+8 format (without minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+8', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC-5 format (without minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC-5', + ); + + expect(adjustedTime.hour, 7); + expect(offset, const Duration(hours: -5)); + }); + + test('should handle plain UTC format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC', + ); + + expect(adjustedTime.hour, 12); + expect(offset, Duration.zero); + }); + + test('should handle lowercase utc format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'utc+08:00', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC+05:30 format (with minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+05:30', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 30); + expect(offset, const Duration(hours: 5, minutes: 30)); + }); + }); + + group('with null or invalid timezone', () { + test('should return UTC time when timezone is null', () { + final localTime = DateTime(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: localTime, + timeZone: null, + ); + + expect(adjustedTime.isUtc, true); + expect(offset, adjustedTime.timeZoneOffset); + }); + + test('should return UTC time when timezone is invalid', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Invalid/Timezone', + ); + + expect(adjustedTime.isUtc, true); + expect(adjustedTime.hour, 12); + expect(offset, adjustedTime.timeZoneOffset); + }); + + test('should return UTC time when UTC offset format is malformed', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC++08', + ); + + expect(adjustedTime.isUtc, true); + expect(adjustedTime.hour, 12); + }); + }); + + group('edge cases', () { + test('should handle date crossing midnight forward', () { + final utcTime = DateTime.utc(2024, 6, 15, 20, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Tokyo', // UTC+9 + ); + + expect(adjustedTime.day, 16); // Crosses to next day + expect(adjustedTime.hour, 5); // 20:00 UTC + 9 = 05:00 next day + expect(offset, const Duration(hours: 9)); + }); + + test('should handle date crossing midnight backward', () { + final utcTime = DateTime.utc(2024, 6, 15, 3, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'America/Los_Angeles', // UTC-7 in summer + ); + + expect(adjustedTime.day, 14); // Crosses to previous day + expect(adjustedTime.hour, 20); // 03:00 UTC - 7 = 20:00 previous day + expect(offset, const Duration(hours: -7)); + }); + + test('should handle year boundary crossing', () { + final utcTime = DateTime.utc(2024, 1, 1, 2, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'America/New_York', // UTC-5 in winter + ); + + expect(adjustedTime.year, 2023); + expect(adjustedTime.month, 12); + expect(adjustedTime.day, 31); + expect(adjustedTime.hour, 21); // 02:00 UTC - 5 = 21:00 Dec 31 + }); + + test('should convert local time to UTC before applying timezone', () { + // Create a local time (not UTC) + final localTime = DateTime(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, _) = applyTimezoneOffset( + dateTime: localTime, + timeZone: 'Asia/Hong_Kong', + ); + + // The function converts to UTC first, then applies timezone + // So local 12:00 -> UTC (depends on local timezone) -> HK time + // We can verify it's working by checking it's a TZDateTime + expect(adjustedTime, isNotNull); + }); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 828fd80033..8bb4338791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,7 +432,7 @@ importers: version: 33.5.0 express: specifier: ^5.1.0 - version: 5.1.0 + version: 5.2.0 fast-glob: specifier: ^3.3.2 version: 3.3.3 @@ -492,7 +492,7 @@ importers: version: 7.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) nodemailer: specifier: ^7.0.0 - version: 7.0.10 + version: 7.0.11 openid-client: specifier: ^6.3.3 version: 6.8.1 @@ -718,7 +718,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.49.2 - version: 0.49.2(svelte@5.43.12) + version: 0.49.2(svelte@5.45.2) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -751,7 +751,7 @@ importers: version: 0.41.3 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.7(svelte@5.43.12) + version: 0.3.7(svelte@5.45.2) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -805,13 +805,13 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.43.12) + version: 4.0.1(svelte@5.45.2) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.5(svelte@5.43.12) + version: 1.2.5(svelte@5.45.2) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.43.12) + version: 0.12.0(svelte@5.45.2) tabbable: specifier: ^6.2.0 version: 6.3.0 @@ -833,16 +833,16 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) + version: 3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) '@sveltejs/enhanced-img': specifier: ^0.8.0 - version: 0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.1 - version: 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) @@ -851,7 +851,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.2.9(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -890,7 +890,7 @@ importers: version: 6.0.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.12) + version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.1(jiti@2.6.1)) @@ -911,19 +911,19 @@ importers: version: 4.1.1(prettier@3.6.2) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.0(prettier@3.6.2)(svelte@5.43.12) + version: 3.4.0(prettier@3.6.2)(svelte@5.45.2) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.53.3) svelte: - specifier: 5.43.12 - version: 5.43.12 + specifier: 5.45.2 + version: 5.45.2 svelte-check: specifier: ^4.1.5 - version: 4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.9.3) + version: 4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.0(svelte@5.43.12) + version: 1.4.0(svelte@5.45.2) tailwindcss: specifier: ^4.1.7 version: 4.1.17 @@ -5537,8 +5537,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} body-parser@2.2.1: @@ -5983,9 +5983,9 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} - content-disposition@1.0.0: - resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} - engines: {node: '>= 0.6'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} @@ -6001,6 +6001,9 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -6009,10 +6012,6 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -6762,8 +6761,8 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} - esrap@2.1.0: - resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} + esrap@2.2.0: + resolution: {integrity: sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -6869,14 +6868,18 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} + express@5.2.0: + resolution: {integrity: sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==} + engines: {node: '>= 18'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -6981,13 +6984,13 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} - finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} - engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} find-cache-dir@4.0.0: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} @@ -8851,8 +8854,8 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nodemailer@7.0.10: - resolution: {integrity: sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==} + nodemailer@7.0.11: + resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==} engines: {node: '>=6.0.0'} nopt@1.0.10: @@ -9863,10 +9866,6 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -9909,8 +9908,8 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} raw-body@3.0.2: @@ -10331,6 +10330,10 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} + send@0.19.1: + resolution: {integrity: sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==} + engines: {node: '>= 0.8.0'} + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -10772,8 +10775,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.43.12: - resolution: {integrity: sha512-d1R+3pFa39LXoHCsxHmV//D2pSFZlEMlnxCVQ54TlrQv+4o5pewJO0/Pc5MUp+j71PJrOrPJHTvREZJHn+ymDQ==} + svelte@5.45.2: + resolution: {integrity: sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -14695,19 +14698,19 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.43.12)': + '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.45.2)': dependencies: - svelte: 5.43.12 + svelte: 5.45.2 - '@immich/ui@0.49.2(svelte@5.43.12)': + '@immich/ui@0.49.2(svelte@5.45.2)': dependencies: - '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.12) + '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.9.8(@internationalized/date@3.10.0)(svelte@5.43.12) + bits-ui: 2.9.8(@internationalized/date@3.10.0)(svelte@5.45.2) luxon: 3.7.2 simple-icons: 15.21.0 - svelte: 5.43.12 + svelte: 5.45.2 svelte-highlight: 7.8.4 tailwind-merge: 3.3.1 tailwind-variants: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.17) @@ -16208,17 +16211,17 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: - '@sveltejs/kit': 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/kit': 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) - '@sveltejs/enhanced-img@0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/enhanced-img@0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.43.12 - svelte-parse-markup: 0.1.5(svelte@5.43.12) + svelte: 5.45.2 + svelte-parse-markup: 0.1.5(svelte@5.45.2) vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite-imagetools: 8.0.0(rollup@4.53.3) zimmerframe: 1.1.4 @@ -16226,11 +16229,11 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16242,27 +16245,27 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.2 sirv: 3.0.2 - svelte: 5.43.12 + svelte: 5.45.2 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) optionalDependencies: '@opentelemetry/api': 1.9.0 - '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) debug: 4.4.3 - svelte: 5.43.12 + svelte: 5.45.2 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 - svelte: 5.43.12 + svelte: 5.45.2 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: @@ -16510,10 +16513,10 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte@5.2.9(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@testing-library/svelte@5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@testing-library/dom': 10.4.1 - svelte: 5.43.12 + svelte: 5.45.2 optionalDependencies: vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) @@ -17240,10 +17243,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.7(svelte@5.43.12)': + '@zoom-image/svelte@0.3.7(svelte@5.45.2)': dependencies: '@zoom-image/core': 0.41.3 - svelte: 5.43.12 + svelte: 5.45.2 abab@2.0.6: optional: true @@ -17606,15 +17609,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.9.8(@internationalized/date@3.10.0)(svelte@5.43.12): + bits-ui@2.9.8(@internationalized/date@3.10.0)(svelte@5.45.2): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.29.2(svelte@5.43.12) - svelte: 5.43.12 - svelte-toolbelt: 0.9.3(svelte@5.43.12) + runed: 0.29.2(svelte@5.45.2) + svelte: 5.45.2 + svelte-toolbelt: 0.9.3(svelte@5.45.2) tabbable: 6.3.0 bl@4.1.0: @@ -17623,18 +17626,18 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.3: + body-parser@1.20.4: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 + qs: 6.14.0 + raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: @@ -18118,9 +18121,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - content-disposition@1.0.0: - dependencies: - safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -18133,12 +18134,12 @@ snapshots: cookie-signature@1.0.6: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.6.0: {} - cookie@0.7.1: {} - cookie@0.7.2: {} cookie@1.0.2: {} @@ -18921,7 +18922,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.12): + eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -18933,9 +18934,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.3 - svelte-eslint-parser: 1.4.0(svelte@5.43.12) + svelte-eslint-parser: 1.4.0(svelte@5.45.2) optionalDependencies: - svelte: 5.43.12 + svelte: 5.45.2 transitivePeerDependencies: - ts-node @@ -19037,7 +19038,7 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.1.0: + esrap@2.2.0: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -19152,36 +19153,36 @@ snapshots: exponential-backoff@3.1.3: {} - express@4.21.2: + express@4.22.1: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.4 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 + cookie: 0.7.2 + cookie-signature: 1.0.7 debug: 2.6.9 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.1 + finalhandler: 1.3.2 fresh: 0.5.2 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 1.0.3 methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.0 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.19.0 + send: 0.19.1 serve-static: 1.16.2 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 1.6.18 utils-merge: 1.0.1 vary: 1.1.2 @@ -19192,7 +19193,7 @@ snapshots: dependencies: accepts: 2.0.0 body-parser: 2.2.1 - content-disposition: 1.0.0 + content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -19200,7 +19201,40 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.0 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + express@5.2.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.1 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -19328,19 +19362,19 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: + finalhandler@1.3.2: dependencies: debug: 2.6.9 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 unpipe: 1.0.0 transitivePeerDependencies: - supports-color - finalhandler@2.1.0: + finalhandler@2.1.1: dependencies: debug: 4.4.3 encodeurl: 2.0.0 @@ -21697,7 +21731,7 @@ snapshots: node-releases@2.0.27: {} - nodemailer@7.0.10: {} + nodemailer@7.0.11: {} nopt@1.0.10: dependencies: @@ -22623,10 +22657,10 @@ snapshots: dependencies: prettier: 3.6.2 - prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.43.12): + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.45.2): dependencies: prettier: 3.6.2 - svelte: 5.43.12 + svelte: 5.45.2 prettier@3.6.2: {} @@ -22739,10 +22773,6 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -22775,10 +22805,10 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@2.5.3: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 unpipe: 1.0.0 @@ -23232,10 +23262,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.29.2(svelte@5.43.12): + runed@0.29.2(svelte@5.45.2): dependencies: esm-env: 1.2.2 - svelte: 5.43.12 + svelte: 5.45.2 rw@1.3.3: {} @@ -23342,6 +23372,24 @@ snapshots: transitivePeerDependencies: - supports-color + send@0.19.1: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + send@1.2.0: dependencies: debug: 4.4.3 @@ -23851,19 +23899,19 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.9.3): + svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.43.12 + svelte: 5.45.2 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.0(svelte@5.43.12): + svelte-eslint-parser@1.4.0(svelte@5.45.2): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -23872,7 +23920,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.0 optionalDependencies: - svelte: 5.43.12 + svelte: 5.45.2 svelte-gestures@5.2.2: {} @@ -23880,7 +23928,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.43.12): + svelte-i18n@4.0.1(svelte@5.45.2): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -23888,34 +23936,34 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.43.12 + svelte: 5.45.2 tiny-glob: 0.2.9 - svelte-maplibre@1.2.5(svelte@5.43.12): + svelte-maplibre@1.2.5(svelte@5.45.2): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.13.0 pmtiles: 3.2.1 - svelte: 5.43.12 + svelte: 5.45.2 - svelte-parse-markup@0.1.5(svelte@5.43.12): + svelte-parse-markup@0.1.5(svelte@5.45.2): dependencies: - svelte: 5.43.12 + svelte: 5.45.2 - svelte-persisted-store@0.12.0(svelte@5.43.12): + svelte-persisted-store@0.12.0(svelte@5.45.2): dependencies: - svelte: 5.43.12 + svelte: 5.45.2 - svelte-toolbelt@0.9.3(svelte@5.43.12): + svelte-toolbelt@0.9.3(svelte@5.45.2): dependencies: clsx: 2.1.1 - runed: 0.29.2(svelte@5.43.12) + runed: 0.29.2(svelte@5.45.2) style-to-object: 1.0.11 - svelte: 5.43.12 + svelte: 5.45.2 - svelte@5.43.12: + svelte@5.45.2: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -23925,8 +23973,9 @@ snapshots: aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 + devalue: 5.5.0 esm-env: 1.2.2 - esrap: 2.1.0 + esrap: 2.2.0 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -24820,7 +24869,7 @@ snapshots: colorette: 2.0.20 compression: 1.8.1 connect-history-api-fallback: 2.0.0 - express: 4.21.2 + express: 4.22.1 graceful-fs: 4.2.11 http-proxy-middleware: 2.0.9(@types/express@4.17.25) ipaddr.js: 2.2.0 diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index eb594fbe47..c2f6aeacef 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -85,19 +85,6 @@ describe(AssetMediaController.name, () => { expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON'])); }); - it('should validate iCloudId is a string', async () => { - const { status, body } = await request(ctx.getHttpServer()) - .post('/assets') - .attach('assetData', assetData, filename) - .field({ - ...makeUploadDto(), - metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]), - }); - - expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string'])); - }); - it('should require `deviceAssetId`', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/assets') diff --git a/server/src/database.ts b/server/src/database.ts index afb46d7834..54bc0092a0 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -154,13 +154,6 @@ export type StorageAsset = { encodedVideoPath: string | null; }; -export type SidecarWriteAsset = { - id: string; - sidecarPath: string | null; - originalPath: string; - tags: Array<{ value: string }>; -}; - export type Stack = { id: string; primaryAssetId: string; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index dc43a0200c..03d1e31fb9 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -19,7 +19,6 @@ import { import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { AssetMetadata, AssetMetadataItem } from 'src/types'; import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -154,23 +153,12 @@ export class AssetMetadataUpsertDto { items!: AssetMetadataUpsertItemDto[]; } -export class AssetMetadataUpsertItemDto implements AssetMetadataItem { +export class AssetMetadataUpsertItemDto { @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) key!: AssetMetadataKey; @IsObject() - @ValidateNested() - @Type((options) => { - switch (options?.object.key) { - case AssetMetadataKey.MobileApp: { - return AssetMetadataMobileAppDto; - } - default: { - return Object; - } - } - }) - value!: AssetMetadata[AssetMetadataKey]; + value!: object; } export class AssetMetadataMobileAppDto { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 9cda1216db..d6dc564458 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -484,21 +484,26 @@ select "asset_exif"."fileSizeInByte", ( select - "asset_file"."path" + coalesce(json_agg(agg), '[]') from - "asset_file" - where - "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $1 - limit - $2 - ) as "sidecarPath" + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as agg + ) as "files" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."deletedAt" is null - and "asset"."id" = $3 + and "asset"."id" = $2 -- AssetJobRepository.streamForStorageTemplateJob select @@ -515,15 +520,20 @@ select "asset_exif"."fileSizeInByte", ( select - "asset_file"."path" + coalesce(json_agg(agg), '[]') from - "asset_file" - where - "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $1 - limit - $2 - ) as "sidecarPath" + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as agg + ) as "files" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 6cf3ec2f54..01cc6a7a89 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -216,6 +216,34 @@ from limit 3 +-- AssetRepository.getForCopy +select + "id", + "stackId", + "originalPath", + "isFavorite", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + ) as agg + ) as "files" +from + "asset" +where + "id" = $1::uuid +limit + $2 + -- AssetRepository.getById select "asset".* diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index d092d70d4f..e79f1b4a3d 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -6,7 +6,6 @@ import { Asset, columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFileType, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; -import { StorageAsset } from 'src/types'; import { anyUuid, asUuid, @@ -324,15 +323,13 @@ export class AssetJobRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getForStorageTemplateJob(id: string): Promise { - return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst() as Promise< - StorageAsset | undefined - >; + getForStorageTemplateJob(id: string) { + return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst(); } @GenerateSql({ params: [], stream: true }) streamForStorageTemplateJob() { - return this.storageTemplateAssetQuery().stream() as AsyncIterableIterator; + return this.storageTemplateAssetQuery().stream(); } @GenerateSql({ params: [DummyValue.DATE], stream: true }) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 06b0e7d607..3797302c23 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -11,7 +11,6 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { AssetMetadataItem } from 'src/types'; import { anyUuid, asUuid, @@ -228,7 +227,7 @@ export class AssetRepository { .execute(); } - upsertMetadata(id: string, items: AssetMetadataItem[]) { + upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) { return this.db .insertInto('asset_metadata') .values(items.map((item) => ({ assetId: id, ...item }))) @@ -256,8 +255,23 @@ export class AssetRepository { await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); } - create(asset: Insertable) { - return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); + create(asset: Insertable, files?: Insertable[]) { + return this.db.transaction().execute(async (trx) => { + const createdAsset = await trx.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); + if (files && files.length > 0) { + const values = files.map((f) => ({ ...f, assetId: createdAsset.id })); + + await trx.insertInto('asset_file').values(values).returningAll().execute(); + } + + const assetWithFiles = await trx + .selectFrom('asset') + .selectAll('asset') + .select(withOriginals) + .where('asset.id', '=', asUuid(createdAsset.id)) + .executeTakeFirstOrThrow(); + return assetWithFiles; + }); } createAll(assets: Insertable[]) { @@ -403,6 +417,16 @@ export class AssetRepository { return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute(); } + getForCopy(id: string) { + return this.db + .selectFrom('asset') + .select(['id', 'stackId', 'isFavorite']) + .select(withFiles) + .where('id', '=', asUuid(id)) + .limit(1) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID] }) getById( id: string, @@ -488,6 +512,7 @@ export class AssetRepository { return this.db .selectFrom('asset') .selectAll('asset') + .select(withOriginals) .where('ownerId', '=', asUuid(ownerId)) .where('checksum', '=', checksum) .$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null))) @@ -854,12 +879,8 @@ export class AssetRepository { .execute(); } - async deleteFile(file: Pick, 'assetId' | 'type'>): Promise { - await this.db - .deleteFrom('asset_file') - .where('assetId', '=', asUuid(file.assetId)) - .where('type', '=', file.type) - .execute(); + async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise { + await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute(); } async deleteFiles(files: Pick, 'id'>[]): Promise { diff --git a/server/src/repositories/ocr.repository.ts b/server/src/repositories/ocr.repository.ts index 1da9a96ec5..a39f0d368c 100644 --- a/server/src/repositories/ocr.repository.ts +++ b/server/src/repositories/ocr.repository.ts @@ -45,12 +45,12 @@ export class OcrRepository { textScore: DummyValue.NUMBER, }, ], + DummyValue.STRING, ], }) - upsert(assetId: string, ocrDataList: Insertable[]) { + upsert(assetId: string, ocrDataList: Insertable[], searchText: string) { let query = this.db.with('deleted_ocr', (db) => db.deleteFrom('asset_ocr').where('assetId', '=', assetId)); if (ocrDataList.length > 0) { - const searchText = ocrDataList.map((item) => item.text.trim()).join(' '); (query as any) = query .with('inserted_ocr', (db) => db.insertInto('asset_ocr').values(ocrDataList)) .with('inserted_search', (db) => diff --git a/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts b/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts new file mode 100644 index 0000000000..3f5cb5fa8f --- /dev/null +++ b/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from 'kysely'; +import { tokenizeForSearch } from 'src/utils/database'; + +export async function up(db: Kysely): Promise { + await sql`truncate ${sql.table('ocr_search')}`.execute(db); + + let lastAssetId: string | undefined; + while (true) { + const rows = await db + .selectFrom('asset_ocr') + .select(['assetId', sql`string_agg(text, ' ')`.as('text')]) + .$if(lastAssetId !== undefined, (qb) => qb.where('assetId', '>', lastAssetId)) + .groupBy('assetId') + .orderBy('assetId') + .limit(5000) + .execute(); + + if (rows.length === 0) { + break; + } + + await db + .insertInto('ocr_search') + .values(rows.map(({ assetId, text }) => ({ assetId, text: tokenizeForSearch(text).join(' ') }))) + .execute(); + + lastAssetId = rows.at(-1)!.assetId; + } +} + +export async function down(): Promise {} diff --git a/server/src/schema/migrations/1764698859174-SidecarInAssetFile.ts b/server/src/schema/migrations/1764698859174-SidecarInAssetFile.ts new file mode 100644 index 0000000000..183a77832e --- /dev/null +++ b/server/src/schema/migrations/1764698859174-SidecarInAssetFile.ts @@ -0,0 +1,24 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`INSERT INTO "asset_file" ("assetId", "path", "type") + SELECT + id, "sidecarPath", 'sidecar' + FROM "asset" + WHERE "sidecarPath" IS NOT NULL AND "sidecarPath" != '';`.execute(db); + + await sql`ALTER TABLE "asset" DROP COLUMN "sidecarPath";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD "sidecarPath" character varying;`.execute(db); + + await sql` + UPDATE "asset" + SET "sidecarPath" = "asset_file"."path" + FROM "asset_file" + WHERE "asset"."id" = "asset_file"."assetId" AND "asset_file"."type" = 'sidecar'; + `.execute(db); + + await sql`DELETE FROM "asset_file" WHERE "type" = 'sidecar';`.execute(db); +} diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index 486101408d..d529d6ad7b 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -12,7 +12,6 @@ import { Timestamp, UpdateDateColumn, } from 'src/sql-tools'; -import { AssetMetadata, AssetMetadataItem } from 'src/types'; @UpdatedAtTrigger('asset_metadata_updated_at') @Table('asset_metadata') @@ -22,7 +21,7 @@ import { AssetMetadata, AssetMetadataItem } from 'src/types'; referencingOldTableAs: 'old', when: 'pg_trigger_depth() = 0', }) -export class AssetMetadataTable implements AssetMetadataItem { +export class AssetMetadataTable { @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', @@ -33,10 +32,10 @@ export class AssetMetadataTable; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 505aeeb39d..95eb8b3c97 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -187,7 +187,6 @@ const existingAsset = Object.freeze({ const sidecarAsset = Object.freeze({ ...existingAsset, - sidecarPath: 'sidecar-path', checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), }) as MapAsset; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 9b231a3d5a..d2e1c14210 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -366,7 +366,7 @@ export class AssetMediaService extends BaseService { }); await (sidecarPath - ? this.assetRepository.upsertFile({ assetId, path: sidecarPath, type: AssetFileType.Sidecar }) + ? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath }) : this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar })); await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a77101ce4a..0a9aa7f355 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -207,8 +207,8 @@ export class AssetService extends BaseService { }: AssetCopyDto, ) { await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] }); - const sourceAsset = await this.assetRepository.getById(sourceId, { files: true }); - const targetAsset = await this.assetRepository.getById(targetId, { files: true }); + const sourceAsset = await this.assetRepository.getForCopy(sourceId); + const targetAsset = await this.assetRepository.getForCopy(targetId); if (!sourceAsset || !targetAsset) { throw new BadRequestException('Both assets must exist'); @@ -262,27 +262,20 @@ export class AssetService extends BaseService { sourceAsset, targetAsset, }: { - sourceAsset: { files?: AssetFile[] }; - targetAsset: { id: string; files?: AssetFile[]; originalPath: string }; + sourceAsset: { files: AssetFile[] }; + targetAsset: { id: string; files: AssetFile[]; originalPath: string }; }) { - if (!sourceAsset.files) { + const { sidecarFile: sourceFile } = getAssetFiles(sourceAsset.files); + if (!sourceFile?.path) { return; } - const sourceSidecarPath = getAssetFiles(sourceAsset.files).sidecarFile?.path; - - if (!sourceSidecarPath) { - return; + const { sidecarFile: targetFile } = getAssetFiles(targetAsset.files ?? []); + if (targetFile?.path) { + await this.storageRepository.unlink(targetFile.path); } - if (targetAsset.files) { - const targetSidecar = getAssetFiles(targetAsset.files).sidecarFile; - if (targetSidecar) { - await this.storageRepository.unlink(targetSidecar.path); - } - } - - await this.storageRepository.copyFile(sourceSidecarPath, `${targetAsset.originalPath}.xmp`); + await this.storageRepository.copyFile(sourceFile.path, `${targetAsset.originalPath}.xmp`); await this.assetRepository.upsertFile({ assetId: targetAsset.id, path: `${targetAsset.originalPath}.xmp`, diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 5f78fa3629..841fa4743c 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -223,7 +223,14 @@ export class LibraryService extends BaseService { ownerId: dto.ownerId, name: dto.name ?? 'New External Library', importPaths: dto.importPaths ?? [], - exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*', '**/#recycle/**', '**/#snapshot/**'], + exclusionPatterns: dto.exclusionPatterns ?? [ + '**/@eaDir/**', + '**/._*', + '**/#recycle/**', + '**/#snapshot/**', + '**/.stversions/**', + '**/.stfolder/**', + ], }); return mapLibrary(library); } diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 54ddc0de48..bb459ff849 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -167,6 +167,8 @@ export class MediaService extends BaseService { return JobStatus.Skipped; } + const { originalFile } = getAssetFiles(asset.files); + let generated: { previewPath: string; thumbnailPath: string; @@ -174,9 +176,11 @@ export class MediaService extends BaseService { thumbhash: Buffer; }; if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { - generated = await this.generateVideoThumbnails(asset); + this.logger.verbose(`Thumbnail generation for video ${id} ${originalFile.path}`); + generated = await this.generateVideoThumbnails(asset, originalFile.path); } else if (asset.type === AssetType.Image) { - generated = await this.generateImageThumbnails(asset); + this.logger.verbose(`Thumbnail generation for image ${id} ${originalFile.path}`); + generated = await this.generateImageThumbnails(asset, originalFile.path); } else { this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); return JobStatus.Skipped; @@ -421,13 +425,13 @@ export class MediaService extends BaseService { }; } - private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { + private async generateVideoThumbnails(asset: ThumbnailPathEntity, originalPath: string) { const { image, ffmpeg } = await this.getConfig({ withCache: true }); const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); - const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(originalPath); const mainVideoStream = this.getMainStream(videoStreams); if (!mainVideoStream) { throw new Error(`No video streams found for asset ${asset.id}`); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 1d39169f3e..8e91c232f7 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -6,7 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; const DAYS = 3; @@ -15,15 +15,6 @@ export class MemoryService extends BaseService { @OnJob({ name: JobName.MemoryGenerate, queue: QueueName.BackgroundTask }) async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); - const usersIds = await Promise.all( - users.map((user) => - getMyPartnerIds({ - userId: user.id, - repository: this.partnerRepository, - timelineEnabled: true, - }), - ), - ); await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => { const state = await this.systemMetadataRepository.get(SystemMetadataKey.MemoriesState); @@ -38,7 +29,7 @@ export class MemoryService extends BaseService { } try { - await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); + await Promise.all(users.map((owner) => this.createOnThisDayMemories(owner.id, target))); } catch (error) { this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`); } @@ -51,10 +42,10 @@ export class MemoryService extends BaseService { }); } - private async createOnThisDayMemories(ownerId: string, userIds: string[], target: DateTime) { + private async createOnThisDayMemories(ownerId: string, target: DateTime) { const showAt = target.startOf('day').toISO(); const hideAt = target.endOf('day').toISO(); - const memories = await this.assetRepository.getByDayOfYear([ownerId, ...userIds], target); + const memories = await this.assetRepository.getByDayOfYear([ownerId], target); await Promise.all( memories.map(({ year, assets }) => this.memoryRepository.create( diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index a4e679dad3..46d6fe7abc 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1651,7 +1651,7 @@ describe(MetadataService.name, () => { dateTimeOriginal: date, }), ).resolves.toBe(JobStatus.Success); - expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, { + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Description: description, ImageDescription: description, DateTimeOriginal: date, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 6e6d7daf8a..a039fae0c3 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -224,11 +224,7 @@ export class MetadataService extends BaseService { return; } - const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; - if (!originalFile) { - this.logger.warn(`Asset ${asset.id} has no original file, skipping metadata extraction`); - return; - } + const { originalFile } = getAssetFiles(asset.files); const [exifTags, stats] = await Promise.all([ this.getExifTags(asset), @@ -307,11 +303,11 @@ export class MetadataService extends BaseService { ]; if (this.isMotionPhoto(asset, exifTags)) { - promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); + promises.push(this.applyMotionPhotos(asset, originalFile.path, exifTags, dates, stats)); } if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { - promises.push(this.applyTaggedFaces(asset, exifTags)); + promises.push(this.applyTaggedFaces(asset, originalFile.path, exifTags)); } await Promise.all(promises); @@ -357,7 +353,7 @@ export class MetadataService extends BaseService { } let sidecarPath = null; - for (const candidate of this.getSidecarCandidates(asset.files)) { + for (const candidate of this.getSidecarCandidates(asset)) { const exists = await this.storageRepository.checkFileExists(candidate, constants.R_OK); if (!exists) { continue; @@ -367,12 +363,12 @@ export class MetadataService extends BaseService { break; } - const existingSidecar = asset.files?.find((file) => file.type === AssetFileType.Sidecar) ?? null; + const { originalFile, sidecarFile } = getAssetFiles(asset.files); - const isChanged = sidecarPath !== existingSidecar?.path; + const isChanged = sidecarPath !== sidecarFile?.path; this.logger.debug( - `Sidecar check found old=${existingSidecar?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`, + `Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${originalFile.path}`, ); if (!isChanged) { @@ -406,14 +402,9 @@ export class MetadataService extends BaseService { const tagsList = (asset.tags || []).map((tag) => tag.value); - const existingSidecar = asset.files?.find((file) => file.type === AssetFileType.Sidecar) ?? null; - const original = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; + const { originalFile, sidecarFile } = getAssetFiles(asset.files); - if (!original) { - throw new Error(`Asset ${asset.id} has no original file`); - } - - const sidecarPath = existingSidecar?.path || `${original.path}.xmp`; // prefer file.jpg.xmp by default + const sidecarPath = sidecarFile?.path || `${originalFile.path}.xmp`; // prefer file.jpg.xmp by default const exif = _.omitBy( { Description: description, @@ -440,21 +431,16 @@ export class MetadataService extends BaseService { return JobStatus.Success; } - private getSidecarCandidates(files: AssetFile[] | null) { - const original = files?.find((file) => file.type === AssetFileType.Original); - if (!original) { - return []; - } - + private getSidecarCandidates({ id, files }: { id: string; files: AssetFile[] }) { const candidates: string[] = []; - const existingSidecar = files?.find((file) => file.type === AssetFileType.Sidecar); + const { originalFile, sidecarFile } = getAssetFiles(files); - if (existingSidecar) { - candidates.push(existingSidecar.path); + if (sidecarFile?.path) { + candidates.push(sidecarFile.path); } - const assetPath = parse(original.path); + const assetPath = parse(originalFile.path); candidates.push( // IMG_123.jpg.xmp @@ -482,26 +468,8 @@ export class MetadataService extends BaseService { return { width, height }; } - private async getExifTags(asset: { id; files: AssetFile[]; type: AssetType }): Promise { - const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; - - if (!originalFile) { - throw new Error(`Asset ${asset.id} has no original file`); - } - - if (asset.type === AssetType.Image) { - const hasSidecar = asset.files?.some(({ type }) => type === AssetFileType.Sidecar); - - if (!hasSidecar) { - return this.metadataRepository.readTags(originalFile.path); - } - } - - if (asset.files && asset.files.length > 1) { - throw new Error(`Asset ${originalFile.path} has multiple sidecar files`); - } - - const sidecarFile = asset.files ? getAssetFiles(asset.files).sidecarFile : undefined; + private async getExifTags(asset: { files: AssetFile[]; type: AssetType }): Promise { + const { originalFile, sidecarFile } = getAssetFiles(asset.files); const [mediaTags, sidecarTags, videoTags] = await Promise.all([ this.metadataRepository.readTags(originalFile.path), @@ -632,21 +600,29 @@ export class MetadataService extends BaseService { if (!motionAsset) { try { const motionAssetId = this.cryptoRepository.randomUUID(); - motionAsset = await this.assetRepository.create({ - id: motionAssetId, - libraryId: asset.libraryId, - type: AssetType.Video, - fileCreatedAt: dates.dateTimeOriginal, - fileModifiedAt: stats.mtime, - localDateTime: dates.localDateTime, - checksum, - ownerId: asset.ownerId, - files: [{ type: AssetFileType.Original, path: StorageCore.getAndroidMotionPath(asset, motionAssetId) }], - originalFileName: `${parse(asset.originalFileName).name}.mp4`, - visibility: AssetVisibility.Hidden, - deviceAssetId: 'NONE', - deviceId: 'NONE', - }); + motionAsset = await this.assetRepository.create( + { + id: motionAssetId, + libraryId: asset.libraryId, + type: AssetType.Video, + fileCreatedAt: dates.dateTimeOriginal, + fileModifiedAt: stats.mtime, + localDateTime: dates.localDateTime, + checksum, + ownerId: asset.ownerId, + originalFileName: `${parse(originalPath).name}.mp4`, + visibility: AssetVisibility.Hidden, + deviceAssetId: 'NONE', + deviceId: 'NONE', + }, + [ + { + type: AssetFileType.Original, + assetId: motionAssetId, + path: StorageCore.getAndroidMotionPath(asset, motionAssetId), + }, + ], + ); isNewMotionAsset = true; @@ -660,16 +636,18 @@ export class MetadataService extends BaseService { motionAsset = await this.assetRepository.getByChecksum(checksumQuery); if (!motionAsset) { - this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`); + this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${originalPath}`); return; } } } + const { originalFile: originalMotionFile } = getAssetFiles(motionAsset.files); + if (!isNewMotionAsset) { this.logger.debugFn(() => { const base64Checksum = checksum.toString('base64'); - return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`; + return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${originalPath}`; }); } @@ -699,22 +677,18 @@ export class MetadataService extends BaseService { } // write extracted motion video to disk, especially if the encoded-video folder has been deleted - const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); + const existsOnDisk = await this.storageRepository.checkFileExists(originalMotionFile.path); if (!existsOnDisk) { - this.storageCore.ensureFolders(motionAsset.originalPath); - await this.storageRepository.createFile(motionAsset.originalPath, video); - this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); - + this.storageCore.ensureFolders(originalMotionFile.path); + await this.storageRepository.createFile(originalMotionFile.path, video); + this.logger.log(`Wrote motion photo video to ${originalMotionFile.path}`); await this.handleMetadataExtraction({ id: motionAsset.id }); await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } }); } - this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); + this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${originalPath}`); } catch (error: Error | any) { - this.logger.error( - `Failed to extract motion video for ${asset.id}: ${asset.originalPath}: ${error}`, - error?.stack, - ); + this.logger.error(`Failed to extract motion video for ${asset.id}: ${originalPath}: ${error}`, error?.stack); } } @@ -799,7 +773,8 @@ export class MetadataService extends BaseService { } private async applyTaggedFaces( - asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string }, + asset: { id: string; ownerId: string; faces: AssetFace[] }, + originalPath: string, tags: ImmichTags, ) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { @@ -853,13 +828,11 @@ export class MetadataService extends BaseService { const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id); if (facesToRemove.length > 0) { - this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`); + this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${originalPath}`); } if (facesToAdd.length > 0) { - this.logger.debug( - `Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${asset.originalPath}`, - ); + this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${originalPath}`); } if (facesToRemove.length > 0 || facesToAdd.length > 0) { @@ -880,9 +853,7 @@ export class MetadataService extends BaseService { const result = firstDateTime(exifTags); const tag = result?.tag; const dateTime = result?.dateTime; - this.logger.verbose( - `Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`, - ); + this.logger.verbose(`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${originalPath}`); // timezone let timeZone = exifTags.tz ?? null; @@ -893,11 +864,9 @@ export class MetadataService extends BaseService { } if (timeZone) { - this.logger.verbose( - `Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, - ); + this.logger.verbose(`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${originalPath}`); } else { - this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); + this.logger.debug(`No timezone information found for asset ${asset.id}: ${originalPath}`); } let dateTimeOriginal = dateTime?.toDateTime(); @@ -923,12 +892,12 @@ export class MetadataService extends BaseService { ), ); this.logger.debug( - `No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, + `No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${originalPath}`, ); dateTimeOriginal = localDateTime = earliestDate; } - this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`); + this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${originalPath}`); return { timeZone, diff --git a/server/src/services/ocr.service.spec.ts b/server/src/services/ocr.service.spec.ts index 6eedba1a5f..404f423cac 100644 --- a/server/src/services/ocr.service.spec.ts +++ b/server/src/services/ocr.service.spec.ts @@ -12,8 +12,21 @@ describe(OcrService.name, () => { ({ sut, mocks } = newTestService(OcrService)); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); + mocks.assetJob.getForOcr.mockResolvedValue({ + visibility: AssetVisibility.Timeline, + previewFile: assetStub.image.files[1].path, + }); }); + const mockOcrResult = (...texts: string[]) => { + mocks.machineLearning.ocr.mockResolvedValue({ + box: texts.flatMap((_, i) => Array.from({ length: 8 }, (_, j) => i * 10 + j)), + boxScore: texts.map(() => 0.9), + text: texts, + textScore: texts.map(() => 0.95), + }); + }; + it('should work', () => { expect(sut).toBeDefined(); }); @@ -72,10 +85,6 @@ describe(OcrService.name, () => { text: ['One Two Three', 'Four Five'], textScore: [0.95, 0.85], }); - mocks.assetJob.getForOcr.mockResolvedValue({ - visibility: AssetVisibility.Timeline, - previewFile: assetStub.image.files[1].path, - }); expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); @@ -88,36 +97,40 @@ describe(OcrService.name, () => { maxResolution: 736, }), ); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [ - { - assetId: assetStub.image.id, - boxScore: 0.9, - text: 'One Two Three', - textScore: 0.95, - x1: 10, - y1: 20, - x2: 30, - y2: 40, - x3: 50, - y3: 60, - x4: 70, - y4: 80, - }, - { - assetId: assetStub.image.id, - boxScore: 0.8, - text: 'Four Five', - textScore: 0.85, - x1: 90, - y1: 100, - x2: 110, - y2: 120, - x3: 130, - y3: 140, - x4: 150, - y4: 160, - }, - ]); + expect(mocks.ocr.upsert).toHaveBeenCalledWith( + assetStub.image.id, + [ + { + assetId: assetStub.image.id, + boxScore: 0.9, + text: 'One Two Three', + textScore: 0.95, + x1: 10, + y1: 20, + x2: 30, + y2: 40, + x3: 50, + y3: 60, + x4: 70, + y4: 80, + }, + { + assetId: assetStub.image.id, + boxScore: 0.8, + text: 'Four Five', + textScore: 0.85, + x1: 90, + y1: 100, + x2: 110, + y2: 120, + x3: 130, + y3: 140, + x4: 150, + y4: 160, + }, + ], + 'One Two Three Four Five', + ); }); it('should apply config settings', async () => { @@ -133,11 +146,7 @@ describe(OcrService.name, () => { }, }, }); - mocks.machineLearning.ocr.mockResolvedValue({ box: [], boxScore: [], text: [], textScore: [] }); - mocks.assetJob.getForOcr.mockResolvedValue({ - visibility: AssetVisibility.Timeline, - previewFile: assetStub.image.files[1].path, - }); + mockOcrResult(); expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); @@ -150,7 +159,7 @@ describe(OcrService.name, () => { maxResolution: 1500, }), ); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, []); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [], ''); }); it('should skip invisible assets', async () => { @@ -173,5 +182,83 @@ describe(OcrService.name, () => { expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); }); + + describe('search tokenization', () => { + it('should generate bigrams for Chinese text', async () => { + mockOcrResult('機器學習'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習'); + }); + + it('should generate bigrams for Japanese text', async () => { + mockOcrResult('テスト'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'テス スト'); + }); + + it('should generate bigrams for Korean text', async () => { + mockOcrResult('한국어'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '한국 국어'); + }); + + it('should pass through Latin text unchanged', async () => { + mockOcrResult('Hello World'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + }); + + it('should handle mixed CJK and Latin text', async () => { + mockOcrResult('機器學習Model'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習 Model'); + }); + + it('should handle year followed by CJK', async () => { + mockOcrResult('2024年レポート'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith( + assetStub.image.id, + expect.any(Array), + '2024 年レ レポ ポー ート', + ); + }); + + it('should join multiple OCR boxes', async () => { + mockOcrResult('機器', 'Learning'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 Learning'); + }); + + it('should normalize whitespace', async () => { + mockOcrResult(' Hello World '); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + }); + + it('should keep single CJK characters', async () => { + mockOcrResult('A', '中', 'B'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'A 中 B'); + }); + }); }); }); diff --git a/server/src/services/ocr.service.ts b/server/src/services/ocr.service.ts index cba57e5bc7..d92d399dba 100644 --- a/server/src/services/ocr.service.ts +++ b/server/src/services/ocr.service.ts @@ -5,6 +5,7 @@ import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum'; import { OCR } from 'src/repositories/machine-learning.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { tokenizeForSearch } from 'src/utils/database'; import { isOcrEnabled } from 'src/utils/misc'; @Injectable() @@ -53,8 +54,8 @@ export class OcrService extends BaseService { } const ocrResults = await this.machineLearningRepository.ocr(asset.previewFile, machineLearning.ocr); - - await this.ocrRepository.upsert(id, this.parseOcrResults(id, ocrResults)); + const { ocrDataList, searchText } = this.parseOcrResults(id, ocrResults); + await this.ocrRepository.upsert(id, ocrDataList, searchText); await this.assetRepository.upsertJobStatus({ assetId: id, ocrAt: new Date() }); @@ -64,7 +65,9 @@ export class OcrService extends BaseService { private parseOcrResults(id: string, { box, boxScore, text, textScore }: OCR) { const ocrDataList = []; + const searchTokens = []; for (let i = 0; i < text.length; i++) { + const rawText = text[i]; const boxOffset = i * 8; ocrDataList.push({ assetId: id, @@ -78,9 +81,11 @@ export class OcrService extends BaseService { y4: box[boxOffset + 7], boxScore: boxScore[i], textScore: textScore[i], - text: text[i], + text: rawText, }); + searchTokens.push(...tokenizeForSearch(rawText)); } - return ocrDataList; + + return { ocrDataList, searchText: searchTokens.join(' ') }; } } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 1d38bf7011..5e1b723112 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -6,10 +6,20 @@ import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; -import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; +import { + AssetFileType, + AssetPathType, + AssetType, + DatabaseLock, + JobName, + JobStatus, + QueueName, + StorageFolder, +} from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { JobOf, StorageAsset } from 'src/types'; +import { getAssetFile, getAssetFiles } from 'src/utils/asset.util'; import { getLivePhotoMotionFilename } from 'src/utils/file'; const storageTokens = { @@ -137,7 +147,8 @@ export class StorageTemplateService extends BaseService { const user = await this.userRepository.get(asset.ownerId, {}); const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; - await this.moveAsset(asset, { storageLabel, filename }); + const { originalFile } = getAssetFiles(asset.files); + await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename }); // move motion part of live photo if (asset.livePhotoVideoId) { @@ -145,8 +156,12 @@ export class StorageTemplateService extends BaseService { if (!livePhotoVideo) { return JobStatus.Failed; } - const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); - await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); + const { originalFile: livePhotoOriginalFile } = getAssetFiles(livePhotoVideo.files); + const motionFilename = getLivePhotoMotionFilename(filename, livePhotoOriginalFile.path); + await this.moveAsset( + { originalPath: livePhotoOriginalFile.path, ...livePhotoVideo }, + { storageLabel, filename: motionFilename }, + ); } return JobStatus.Success; } @@ -170,7 +185,8 @@ export class StorageTemplateService extends BaseService { const user = users.find((user) => user.id === asset.ownerId); const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; - await this.moveAsset(asset, { storageLabel, filename }); + const { originalFile } = getAssetFiles(asset.files); + await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename }); } this.logger.debug('Cleaning up empty directories...'); @@ -196,7 +212,7 @@ export class StorageTemplateService extends BaseService { } return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => { - const { id, sidecarPath, originalPath, checksum, fileSizeInByte } = asset; + const { id, originalPath, checksum, fileSizeInByte } = asset; const oldPath = originalPath; const newPath = await this.getTemplatePath(asset, metadata); @@ -213,6 +229,8 @@ export class StorageTemplateService extends BaseService { newPath, assetInfo: { sizeInBytes: fileSizeInByte, checksum }, }); + + const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path; if (sidecarPath) { await this.storageCore.moveFile({ entityId: id, diff --git a/server/src/types.ts b/server/src/types.ts index 848d19177d..a33dba490c 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,10 +1,9 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; -import { Asset } from 'src/database'; +import { Asset, AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { - AssetMetadataKey, AssetOrder, AssetType, DatabaseSslMode, @@ -476,8 +475,8 @@ export type StorageAsset = { fileCreatedAt: Date; originalPath: string; originalFileName: string; - sidecarPath: string | null; fileSizeInByte: number | null; + files: AssetFile[]; }; export type OnThisDayData = { year: number }; @@ -563,12 +562,3 @@ export interface UserMetadata extends Record = { - key: T; - value: AssetMetadata[T]; -}; - -export interface AssetMetadata extends Record> { - [AssetMetadataKey.MobileApp]: { iCloudId: string }; -} diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f5a578392f..319358c16d 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -18,6 +18,13 @@ export const getAssetFile = (files: AssetFile[], type: AssetFileType | Generated }; export const getAssetFiles = (files: AssetFile[]) => ({ + originalFile: (() => { + const file = getAssetFile(files, AssetFileType.Original); + if (!file?.path) { + throw new BadRequestException(`Asset has no original file`); // TODO: should we throw a specific error here that can be caught higher up? + } + return file; + })(), fullsizeFile: getAssetFile(files, AssetFileType.FullSize), previewFile: getAssetFile(files, AssetFileType.Preview), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index daec63f8cb..abb9ddffaf 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -320,6 +320,46 @@ export function withTagId(qb: SelectQueryBuilder, tagId: stri ); } +const isCJK = (c: number): boolean => + (c >= 0x4e_00 && c <= 0x9f_ff) || + (c >= 0xac_00 && c <= 0xd7_af) || + (c >= 0x30_40 && c <= 0x30_9f) || + (c >= 0x30_a0 && c <= 0x30_ff) || + (c >= 0x34_00 && c <= 0x4d_bf); + +export const tokenizeForSearch = (text: string): string[] => { + /* eslint-disable unicorn/prefer-code-point */ + const tokens: string[] = []; + let i = 0; + while (i < text.length) { + const c = text.charCodeAt(i); + if (c <= 32) { + i++; + continue; + } + + const start = i; + if (isCJK(c)) { + while (i < text.length && isCJK(text.charCodeAt(i))) { + i++; + } + if (i - start === 1) { + tokens.push(text[start]); + } else { + for (let k = start; k < i - 1; k++) { + tokens.push(text[k] + text[k + 1]); + } + } + } else { + while (i < text.length && text.charCodeAt(i) > 32 && !isCJK(text.charCodeAt(i))) { + i++; + } + tokens.push(text.slice(start, i)); + } + } + return tokens; +}; + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ @@ -405,7 +445,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.ocr, (qb) => qb .innerJoin('ocr_search', 'asset.id', 'ocr_search.assetId') - .where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${options.ocr!})`), + .where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(options.ocr!).join(' ')})`), ) .$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index e5c0f3fdb4..f5935d5d0e 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -64,7 +64,7 @@ export const assetStub = { originalPath: '/original/path.jpg', originalFileName: 'IMG_123.jpg', fileSizeInByte: 12_345, - sidecarPath: null, + files: [], ...asset, }), noResizePath: Object.freeze({ @@ -553,6 +553,7 @@ export const assetStub = { fileSizeInByte: 100_000, timeZone: `America/New_York`, }, + files: [] as AssetFile[], libraryId: null, visibility: AssetVisibility.Hidden, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), @@ -589,6 +590,7 @@ export const assetStub = { fileSizeInByte: 25_000, timeZone: `America/New_York`, }, + files: [] as AssetFile[], libraryId: null, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 5d44079be5..1d144e9c9c 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -82,7 +82,11 @@ describe(MetadataService.name, () => { process.env.TZ = serverTimeZone ?? undefined; const { filePath } = await createTestFile(exifData); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as any); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + id: 'asset-1', + originalPath: filePath, + files: [], + } as any); await sut.handleMetadataExtraction({ id: 'asset-1' }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index e853deb270..5ba77ddc2f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -10,6 +10,7 @@ export const newAssetRepositoryMock = (): Mocked = {}) => ({ originalFileName: 'IMG_123.jpg', originalPath: `/data/12/34/IMG_123.jpg`, ownerId: newUuid(), - sidecarPath: null, stackId: null, thumbhash: null, type: AssetType.Image, @@ -321,9 +319,8 @@ const versionHistoryFactory = () => ({ version: '1.123.45', }); -const assetSidecarWriteFactory = (asset: Partial = {}) => ({ +const assetSidecarWriteFactory = () => ({ id: newUuid(), - sidecarPath: '/path/to/original-path.jpg.xmp', originalPath: '/path/to/original-path.jpg.xmp', tags: [], files: [ @@ -333,7 +330,6 @@ const assetSidecarWriteFactory = (asset: Partial = {}) => ({ type: AssetFileType.Sidecar, }, ], - ...asset, }); const assetOcrFactory = ( diff --git a/web/package.json b/web/package.json index fca762ef34..c44dd6d4b7 100644 --- a/web/package.json +++ b/web/package.json @@ -97,7 +97,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.43.12", + "svelte": "5.45.2", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7570278e51..b657f34ece 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -512,7 +512,7 @@ {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath .toLowerCase() .endsWith('.insp'))} - + {:else if isShowEditor && selectedEditType === 'crop'} {:else} diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 7334aab4d4..08ba43526d 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -7,11 +7,12 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - interface Props { + type Props = { asset: AssetResponseDto; - } + zoomToggle?: (() => void) | null; + }; - const { asset }: Props = $props(); + let { asset, zoomToggle = $bindable() }: Props = $props(); const loadAssetData = async (id: string) => { const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); @@ -24,6 +25,7 @@ {:then [data, { default: PhotoSphereViewer }]} + import { shortcuts } from '$lib/actions/shortcut'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; + import { photoZoomState } from '$lib/stores/zoom-image.store'; import { EquirectangularAdapter, Viewer, @@ -24,15 +26,23 @@ strokeLinejoin: 'round', }; - interface Props { + type Props = { panorama: string | { source: string }; originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; navbar?: boolean; - } + zoomToggle?: (() => void) | null; + }; - let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); + let { + panorama, + originalPanorama, + adapter = EquirectangularAdapter, + plugins = [], + navbar = false, + zoomToggle = $bindable(), + }: Props = $props(); let container: HTMLDivElement | undefined = $state(); let viewer: Viewer; @@ -93,6 +103,14 @@ } }); + zoomToggle = () => { + if (!viewer) { + return; + } + viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 }); + }; + + let hasChangedResolution: boolean = false; onMount(() => { if (!container) { return; @@ -139,10 +157,15 @@ const resolutionPlugin = viewer.getPlugin(ResolutionPlugin); const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { // zoomLevel range: [0, 100] - if (Math.round(zoomLevel) >= 75) { + photoZoomState.set({ + ...$photoZoomState, + currentZoom: zoomLevel / 50, + }); + + if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) { // Replace the preview with the original void resolutionPlugin.setResolution('original'); - viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + hasChangedResolution = true; } }; @@ -158,7 +181,13 @@ viewer.destroy(); } boundingBoxesUnsubscribe(); + // zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1. + photoZoomState.set({ + ...$photoZoomState, + currentZoom: 1, + }); }); +
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 0645541241..38d734fc22 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -4,7 +4,7 @@ import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { AssetMediaSize, AssetVisibility } from '@immich/sdk'; + import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk'; import { mdiArchiveArrowDownOutline, mdiCameraBurst, @@ -46,6 +46,7 @@ imageClass?: ClassValue; brokenAssetClass?: ClassValue; dimmed?: boolean; + albumUsers?: UserResponseDto[]; onClick?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void; onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void; @@ -64,6 +65,7 @@ readonly = false, showArchiveIcon = false, showStackedIcon = true, + albumUsers = [], onClick = undefined, onSelect = undefined, onMouseEvent = undefined, @@ -85,6 +87,8 @@ let width = $derived(thumbnailSize || thumbnailWidth || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235); + let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null); + const onIconClickedHandler = (e?: MouseEvent) => { e?.stopPropagation(); e?.preventDefault(); @@ -268,6 +272,14 @@ {/if} + {#if !!assetOwner} +
+

+ {assetOwner.name} +

+
+ {/if} + {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte index 108714348f..32622a1547 100644 --- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte @@ -2,13 +2,14 @@ import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { user } from '$lib/stores/user.store'; + import { Link } from '@immich/ui';

{#snippet children({ message })} - {message} + {message} {/snippet}

diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index d2873eca70..ba9cf37bff 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -23,7 +23,7 @@ import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { isAssetViewerRoute, navigate } from '$lib/utils/navigation'; import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; - import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; + import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; import { onDestroy, onMount, type Snippet } from 'svelte'; import type { UpdatePayload } from 'vite'; @@ -49,6 +49,7 @@ showArchiveIcon?: boolean; isShared?: boolean; album?: AlbumResponseDto | null; + albumUsers?: UserResponseDto[]; person?: PersonResponseDto | null; isShowDeleteConfirmation?: boolean; onSelect?: (asset: TimelineAsset) => void; @@ -81,6 +82,7 @@ showArchiveIcon = false, isShared = false, album = null, + albumUsers = [], person = null, isShowDeleteConfirmation = $bindable(false), onSelect = () => {}, @@ -702,6 +704,7 @@ showStackedIcon={withStacked} {showArchiveIcon} {asset} + {albumUsers} {groupIndex} onClick={(asset) => { if (typeof onThumbnailClick === 'function') { diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte index d5b1d2ecf6..b731635355 100644 --- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte +++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte @@ -80,10 +80,7 @@ const toggleArchive = async () => { const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive; const ids = await archiveAssets(assetInteraction.selectedAssets, visibility); - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - }); + timelineManager.update(ids, (asset) => (asset.visibility = visibility)); deselectAllAssets(); }; diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index 934ca1d4ff..e21e54a6e5 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -6,7 +6,7 @@ import { plainDateTimeCompare } from '$lib/utils/timeline-util'; import { SvelteSet } from 'svelte/reactivity'; import type { MonthGroup } from './month-group.svelte'; -import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types'; +import type { Direction, MoveAsset, TimelineAsset } from './types'; import { ViewerAsset } from './viewer-asset.svelte'; export class DayGroup { @@ -101,7 +101,7 @@ export class DayGroup { return this.viewerAssets.map((viewerAsset) => viewerAsset.asset); } - runAssetOperation(ids: Set, operation: AssetOperation) { + runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { if (ids.size === 0) { return { moveAssets: [] as MoveAsset[], @@ -122,7 +122,8 @@ export class DayGroup { const asset = this.viewerAssets[index].asset!; const oldTime = { ...asset.localDateTime }; - let { remove } = operation(asset); + const callbackResult = callback(asset); + let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false; const newTime = asset.localDateTime; if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) { const { year, month, day } = newTime; diff --git a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts deleted file mode 100644 index 4bc99c0315..0000000000 --- a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { setDifference, type TimelineDate } from '$lib/utils/timeline-util'; -import { AssetOrder } from '@immich/sdk'; - -import { SvelteSet } from 'svelte/reactivity'; -import { GroupInsertionCache } from '../group-insertion-cache.svelte'; -import { MonthGroup } from '../month-group.svelte'; -import type { TimelineManager } from '../timeline-manager.svelte'; -import type { AssetOperation, TimelineAsset } from '../types'; -import { updateGeometry } from './layout-support.svelte'; -import { getMonthGroupByDate } from './search-support.svelte'; - -export function addAssetsToMonthGroups( - timelineManager: TimelineManager, - assets: TimelineAsset[], - options: { order: AssetOrder }, -) { - if (assets.length === 0) { - return; - } - - const addContext = new GroupInsertionCache(); - const updatedMonthGroups = new SvelteSet(); - const monthCount = timelineManager.months.length; - for (const asset of assets) { - let month = getMonthGroupByDate(timelineManager, asset.localDateTime); - - if (!month) { - month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order); - month.isLoaded = true; - timelineManager.months.push(month); - } - - month.addTimelineAsset(asset, addContext); - updatedMonthGroups.add(month); - } - - if (timelineManager.months.length !== monthCount) { - timelineManager.months.sort((a, b) => { - return a.yearMonth.year === b.yearMonth.year - ? b.yearMonth.month - a.yearMonth.month - : b.yearMonth.year - a.yearMonth.year; - }); - } - - for (const group of addContext.existingDayGroups) { - group.sortAssets(options.order); - } - - for (const monthGroup of addContext.bucketsWithNewDayGroups) { - monthGroup.sortDayGroups(); - } - - for (const month of addContext.updatedBuckets) { - month.sortDayGroups(); - updateGeometry(timelineManager, month, { invalidateHeight: true }); - } - timelineManager.updateIntersections(); -} - -export function runAssetOperation( - timelineManager: TimelineManager, - ids: Set, - operation: AssetOperation, - options: { order: AssetOrder }, -) { - if (ids.size === 0) { - return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false }; - } - - const changedMonthGroups = new SvelteSet(); - let idsToProcess = new SvelteSet(ids); - const idsProcessed = new SvelteSet(); - const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = []; - for (const month of timelineManager.months) { - if (idsToProcess.size > 0) { - const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation); - if (moveAssets.length > 0) { - combinedMoveAssets.push(moveAssets); - } - idsToProcess = setDifference(idsToProcess, processedIds); - for (const id of processedIds) { - idsProcessed.add(id); - } - if (changedGeometry) { - changedMonthGroups.add(month); - } - } - } - if (combinedMoveAssets.length > 0) { - addAssetsToMonthGroups( - timelineManager, - combinedMoveAssets.flat().map((a) => a.asset), - options, - ); - } - const changedGeometry = changedMonthGroups.size > 0; - for (const month of changedMonthGroups) { - updateGeometry(timelineManager, month, { invalidateHeight: true }); - } - if (changedGeometry) { - timelineManager.updateIntersections(); - } - return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry }; -} diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 1d9e1bbaa7..3926055cca 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -21,7 +21,7 @@ import { SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte'; import type { TimelineManager } from './timeline-manager.svelte'; -import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types'; +import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './types'; import { ViewerAsset } from './viewer-asset.svelte'; export class MonthGroup { @@ -50,12 +50,13 @@ export class MonthGroup { readonly yearMonth: TimelineYearMonth; constructor( - store: TimelineManager, + timelineManager: TimelineManager, yearMonth: TimelineYearMonth, initialCount: number, + loaded: boolean, order: AssetOrder = AssetOrder.Desc, ) { - this.timelineManager = store; + this.timelineManager = timelineManager; this.#initialCount = initialCount; this.#sortOrder = order; @@ -72,6 +73,9 @@ export class MonthGroup { }, this.#handleLoadError, ); + if (loaded) { + this.isLoaded = true; + } } set intersecting(newValue: boolean) { @@ -112,7 +116,7 @@ export class MonthGroup { return this.dayGroups.sort((a, b) => b.day - a.day); } - runAssetOperation(ids: Set, operation: AssetOperation) { + runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { if (ids.size === 0) { return { moveAssets: [] as MoveAsset[], @@ -130,7 +134,7 @@ export class MonthGroup { while (index--) { if (idsToProcess.size > 0) { const group = dayGroups[index]; - const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation); + const { moveAssets, processedIds, changedGeometry } = group.runAssetCallback(ids, callback); if (moveAssets.length > 0) { combinedMoveAssets.push(moveAssets); } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 62053f7a0d..bb58704214 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -278,10 +278,11 @@ describe('TimelineManager', () => { }); it('updates existing asset', () => { + const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets'); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build()); timelineManager.upsertAssets([asset]); - timelineManager.upsertAssets([asset]); + expect(updateAssetsSpy).toBeCalledWith([asset]); expect(timelineManager.assetCount).toEqual(1); }); @@ -691,4 +692,42 @@ describe('TimelineManager', () => { expect(discoveredAssets.size).toBe(assetCount); }); }); + + describe('showAssetOwners', () => { + const LS_KEY = 'album-show-asset-owners'; + + beforeEach(() => { + // ensure clean state + globalThis.localStorage?.removeItem(LS_KEY); + }); + + it('defaults to false', () => { + const timelineManager = new TimelineManager(); + expect(timelineManager.showAssetOwners).toBe(false); + }); + + it('setShowAssetOwners updates value', () => { + const timelineManager = new TimelineManager(); + timelineManager.setShowAssetOwners(true); + expect(timelineManager.showAssetOwners).toBe(true); + timelineManager.setShowAssetOwners(false); + expect(timelineManager.showAssetOwners).toBe(false); + }); + + it('toggleShowAssetOwners flips value', () => { + const timelineManager = new TimelineManager(); + expect(timelineManager.showAssetOwners).toBe(false); + timelineManager.toggleShowAssetOwners(); + expect(timelineManager.showAssetOwners).toBe(true); + timelineManager.toggleShowAssetOwners(); + expect(timelineManager.showAssetOwners).toBe(false); + }); + + it('persists across instances via localStorage', () => { + const a = new TimelineManager(); + a.setShowAssetOwners(true); + const b = new TimelineManager(); + expect(b.showAssetOwners).toBe(true); + }); + }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index e3327663b4..feba73a0f8 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -1,12 +1,9 @@ import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; -import { - addAssetsToMonthGroups, - runAssetOperation, -} from '$lib/managers/timeline-manager/internal/operations-support.svelte'; import { findClosestGroupForDate, findMonthGroupForAsset as findMonthGroupForAssetUtil, @@ -17,17 +14,23 @@ import { } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; +import { PersistedLocalStorage } from '$lib/utils/persisted'; +import { + setDifference, + toTimelineAsset, + type TimelineDateTime, + type TimelineYearMonth, +} from '$lib/utils/timeline-util'; import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { clamp, isEqual } from 'lodash-es'; -import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; +import { SvelteDate, SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; import { isMismatched, updateObject } from './internal/utils.svelte'; import { MonthGroup } from './month-group.svelte'; import type { AssetDescriptor, - AssetOperation, Direction, + MoveAsset, ScrubberMonth, TimelineAsset, TimelineManagerOptions, @@ -88,6 +91,19 @@ export class TimelineManager extends VirtualScrollManager { #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; #updatingIntersections = false; #scrollableElement: HTMLElement | undefined = $state(); + #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false); + + get showAssetOwners() { + return this.#showAssetOwners.current; + } + + setShowAssetOwners(value: boolean) { + this.#showAssetOwners.current = value; + } + + toggleShowAssetOwners() { + this.#showAssetOwners.current = !this.#showAssetOwners.current; + } constructor() { super(); @@ -218,6 +234,7 @@ export class TimelineManager extends VirtualScrollManager { this, { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, timeBucket.count, + false, this.#options.order, ); }); @@ -323,7 +340,7 @@ export class TimelineManager extends VirtualScrollManager { upsertAssets(assets: TimelineAsset[]) { const notUpdated = this.#updateAssets(assets); const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset)); - addAssetsToMonthGroups(this, [...notExcluded], { order: this.#options.order ?? AssetOrder.Desc }); + this.addAssetsUpsertSegments([...notExcluded]); } async findMonthGroupForAsset(id: string) { @@ -400,38 +417,107 @@ export class TimelineManager extends VirtualScrollManager { return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset; } - updateAssetOperation(ids: string[], operation: AssetOperation) { - runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc }); - } - - #updateAssets(assets: TimelineAsset[]) { - const lookup = new SvelteMap(assets.map((asset) => [asset.id, asset])); - const { unprocessedIds } = runAssetOperation( - this, - new SvelteSet(lookup.keys()), - (asset) => { - updateObject(asset, lookup.get(asset.id)); - return { remove: false }; - }, - { order: this.#options.order ?? AssetOrder.Desc }, - ); - const result: TimelineAsset[] = []; - for (const id of unprocessedIds.values()) { - result.push(lookup.get(id)!); - } - return result; + /** + * Executes callback on assets, handling moves between groups and removals due to filter criteria. + */ + update(ids: string[], callback: (asset: TimelineAsset) => void) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + return this.#runAssetCallback(new Set(ids), callback); } removeAssets(ids: string[]) { - const { unprocessedIds } = runAssetOperation( - this, - new SvelteSet(ids), - () => { - return { remove: true }; - }, - { order: this.#options.order ?? AssetOrder.Desc }, - ); - return [...unprocessedIds]; + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const result = this.#runAssetCallback(new Set(ids), () => ({ remove: true })); + return [...result.notUpdated]; + } + + protected upsertSegmentForAsset(asset: TimelineAsset) { + let month = getMonthGroupByDate(this, asset.localDateTime); + + if (!month) { + month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order); + this.months.push(month); + } + return month; + } + + /** + * Adds assets to existing segments, creating new segments as needed. + * + * This is an internal method that assumes the provided assets are not already + * present in the timeline. For updating existing assets, use updateAssetOperation(). + */ + protected addAssetsUpsertSegments(assets: TimelineAsset[]) { + if (assets.length === 0) { + return; + } + const context = new GroupInsertionCache(); + const monthCount = this.months.length; + for (const asset of assets) { + this.upsertSegmentForAsset(asset).addTimelineAsset(asset, context); + } + if (this.months.length !== monthCount) { + this.postCreateSegments(); + } + this.postUpsert(context); + } + + #updateAssets(assets: TimelineAsset[]) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const cache = new Map(assets.map((asset) => [asset.id, asset])); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const idsToUpdate = new Set(cache.keys()); + const result = this.#runAssetCallback(idsToUpdate, (asset) => void updateObject(asset, cache.get(asset.id))); + const notUpdated: TimelineAsset[] = []; + for (const assetId of result.notUpdated) { + notUpdated.push(cache.get(assetId)!); + } + return notUpdated; + } + + #runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { + if (ids.size === 0) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + return { updated: new Set(), notUpdated: ids, changedGeometry: false }; + } + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const changedMonthGroups = new Set(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + let notUpdated = new Set(ids); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const updated = new Set(); + const assetsToMoveSegments: MoveAsset[][] = []; + for (const month of this.months) { + if (notUpdated.size === 0) { + break; + } + const result = month.runAssetCallback(notUpdated, callback); + if (result.moveAssets.length > 0) { + assetsToMoveSegments.push(result.moveAssets); + } + if (result.changedGeometry) { + changedMonthGroups.add(month); + } + notUpdated = setDifference(notUpdated, result.processedIds); + for (const id of result.processedIds) { + updated.add(id); + } + } + const assetsToAdd = []; + for (const segment of assetsToMoveSegments) { + for (const moveAsset of segment) { + assetsToAdd.push(moveAsset.asset); + } + } + this.addAssetsUpsertSegments(assetsToAdd); + const changedGeometry = changedMonthGroups.size > 0; + for (const month of changedMonthGroups) { + updateGeometry(this, month, { invalidateHeight: true }); + } + if (changedGeometry) { + this.updateIntersections(); + } + return { updated, notUpdated, changedGeometry }; } override refreshLayout() { @@ -493,4 +579,28 @@ export class TimelineManager extends VirtualScrollManager { getAssetOrder() { return this.#options.order ?? AssetOrder.Desc; } + + protected postCreateSegments(): void { + this.months.sort((a, b) => { + return a.yearMonth.year === b.yearMonth.year + ? b.yearMonth.month - a.yearMonth.month + : b.yearMonth.year - a.yearMonth.year; + }); + } + + protected postUpsert(context: GroupInsertionCache): void { + for (const group of context.existingDayGroups) { + group.sortAssets(this.#options.order); + } + + for (const monthGroup of context.bucketsWithNewDayGroups) { + monthGroup.sortDayGroups(); + } + + for (const month of context.updatedBuckets) { + month.sortDayGroups(); + updateGeometry(this, month, { invalidateHeight: true }); + } + this.updateIntersections(); + } } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 27c27dcb63..35d7178f97 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -37,8 +37,6 @@ export type TimelineAsset = { longitude?: number | null; }; -export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; - export type MoveAsset = { asset: TimelineAsset; date: TimelineDate }; export interface Viewport { diff --git a/web/src/lib/modals/TagCreateModal.svelte b/web/src/lib/modals/TagCreateModal.svelte index 360681d7b9..5c48e83b08 100644 --- a/web/src/lib/modals/TagCreateModal.svelte +++ b/web/src/lib/modals/TagCreateModal.svelte @@ -14,7 +14,7 @@ const { onClose, baseTag }: Props = $props(); - let tagValue = $state(baseTag?.value ? `${baseTag.value}/` : ''); + let tagValue = $state(baseTag?.path ? `${baseTag.path}/` : ''); const createTag = async () => { const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } }); diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 2eb081a490..05de75d3bc 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -79,14 +79,15 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse */ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) { if (stack != undefined) { - timelineManager.updateAssetOperation([stack.primaryAssetId], (asset) => { - asset.stack = { - id: stack.id, - primaryAssetId: stack.primaryAssetId, - assetCount: stack.assets.length, - }; - return { remove: false }; - }); + timelineManager.update( + [stack.primaryAssetId], + (asset) => + (asset.stack = { + id: stack.id, + primaryAssetId: stack.primaryAssetId, + assetCount: stack.assets.length, + }), + ); timelineManager.removeAssets(toDeleteIds); } @@ -101,7 +102,7 @@ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { * @param assets - The array of asset response DTOs to update in the timeline manager. */ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) { - timelineManager.updateAssetOperation( + timelineManager.update( assets.map((asset) => asset.id), (asset) => { asset.stack = null; diff --git a/web/src/lib/utils/tree-utils.ts b/web/src/lib/utils/tree-utils.ts index 267bb2eec7..64b51158c4 100644 --- a/web/src/lib/utils/tree-utils.ts +++ b/web/src/lib/utils/tree-utils.ts @@ -62,8 +62,16 @@ export class TreeNode extends Map { const child = this.values().next().value!; child.value = joinPaths(this.value, child.value); child.parent = this.parent; - this.parent.delete(this.value); - this.parent.set(child.value, child); + + const entries = Array.from(this.parent.entries()); + this.parent.clear(); + for (const [key, value] of entries) { + if (key === this.value) { + this.parent.set(child.value, child); + } else { + this.parent.set(key, value); + } + } } for (const child of this.values()) { diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index b99260eee4..3f4d3dd39f 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -66,6 +66,8 @@ } from '@immich/sdk'; import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; import { + mdiAccountEye, + mdiAccountEyeOutline, mdiArrowLeft, mdiCogOutline, mdiDeleteOutline, @@ -101,6 +103,9 @@ let isShowActivity = $state(false); let albumOrder: AssetOrder | undefined = $state(data.album.order); + let timelineManager = $state() as TimelineManager; + let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false); + const assetInteraction = new AssetInteraction(); const timelineInteraction = new AssetInteraction(); @@ -290,13 +295,17 @@ let album = $derived(data.album); let albumId = $derived(album.id); + const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)); + const albumUsers = $derived( + showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [], + ); + $effect(() => { if (!album.isActivityEnabled && activityManager.commentCount === 0) { isShowActivity = false; } }); - let timelineManager = $state() as TimelineManager; const options = $derived.by(() => { if (viewMode === AlbumPageViewMode.SELECT_ASSETS) { return { @@ -418,6 +427,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} > {/if} @@ -570,11 +576,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {/if} @@ -657,6 +659,13 @@ color="secondary" offset={{ x: 175, y: 25 }} > + {#if containsEditors} + timelineManager.toggleShowAssetOwners()} + /> + {/if} {#if album.assetCount > 0} - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> @@ -80,11 +76,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} /> diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7cb3bf8e17..781dc80ec8 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -85,11 +85,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {#if $preferences.tags.enabled} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5dabd58e76..c822855310 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -492,11 +492,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} /> @@ -511,11 +507,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 669ea23921..8bf8dce94e 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -120,11 +120,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} > @@ -148,11 +144,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {#if $preferences.tags.enabled}