From 619de2a5e42096daa43fabeedd965da623a6c394 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Mon, 3 Nov 2025 08:31:57 -0500 Subject: [PATCH 01/93] fix(web): search bar accessibility (#23550) * fix: always show search type when search bar is focused * fix: indicate search type to screen reader users --- .../shared-components/search-bar/search-bar.svelte | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index d0e84fd072..8c1d2c5d08 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -30,10 +30,10 @@ let showSuggestions = $state(false); let isSearchSuggestions = $state(false); let selectedId: string | undefined = $state(); - let isFocus = $state(false); let close: (() => Promise) | undefined; const listboxId = generateId(); + const searchTypeId = generateId(); onDestroy(() => { searchStore.isSearchEnabled = false; @@ -161,12 +161,10 @@ const openDropdown = () => { showSuggestions = true; - isFocus = true; }; const closeDropdown = () => { showSuggestions = false; - isFocus = false; searchHistoryBox?.clearSelection(); }; @@ -251,6 +249,7 @@ aria-activedescendant={selectedId ?? ''} aria-expanded={showSuggestions && isSearchSuggestions} aria-autocomplete="list" + aria-describedby={searchTypeId} use:shortcuts={[ { shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, @@ -287,12 +286,12 @@ /> - {#if isFocus} + {#if searchStore.isSearchEnabled}
0} + class:end-28={value.length > 0} >

Date: Mon, 3 Nov 2025 15:02:41 +0100 Subject: [PATCH 02/93] fix: exif gps parsing of malformed data (#23551) * fix: exif gps parsing of malformed data * chore: e2e test --- e2e/src/api/specs/asset.e2e-spec.ts | 10 ++++++++++ e2e/test-assets | 2 +- server/src/services/metadata.service.ts | 14 ++++++-------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 5c30ff5cbe..90d51e7fef 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1140,6 +1140,16 @@ describe('/asset', () => { }, }, }, + { + input: 'metadata/gps-position/empty_gps.jpg', + expected: { + type: AssetTypeEnum.Image, + exifInfo: { + latitude: null, + longitude: null, + }, + }, + }, ]; it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => { diff --git a/e2e/test-assets b/e2e/test-assets index 37f60ea537..68e8b5853c 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 37f60ea537c0228f5f92e4f42dc42f0bb39a6d7f +Subproject commit 68e8b5853cdc2d76c5e6f18a6d1773793728c491 diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index b73a9b6bf0..e76b335c90 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -236,8 +236,8 @@ export class MetadataService extends BaseService { latitude: number | null = null, longitude: number | null = null; if (this.hasGeo(exifTags)) { - latitude = exifTags.GPSLatitude; - longitude = exifTags.GPSLongitude; + latitude = Number(exifTags.GPSLatitude); + longitude = Number(exifTags.GPSLongitude); if (reverseGeocoding.enabled) { geo = await this.mapRepository.reverseGeocode({ latitude, longitude }); } @@ -894,12 +894,10 @@ export class MetadataService extends BaseService { }; } - private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } { - return ( - tags.GPSLatitude !== undefined && - tags.GPSLongitude !== undefined && - (tags.GPSLatitude !== 0 || tags.GPSLatitude !== 0) - ); + private hasGeo(tags: ImmichTags) { + const lat = Number(tags.GPSLatitude); + const lng = Number(tags.GPSLongitude); + return !Number.isNaN(lat) && !Number.isNaN(lng) && (lat !== 0 || lng !== 0); } private getAutoStackId(tags: ImmichTags | null): string | null { From d94cb9641b79938dda3b4711e6e88a334249217b Mon Sep 17 00:00:00 2001 From: Jonathan S Date: Mon, 3 Nov 2025 08:35:56 -0600 Subject: [PATCH 03/93] chore: correct hosted isar paths in fdroid_build_isar.sh (#23529) This should hopefully unblock F-Droid builds, which are a few versions behind. Based on the suggestion in https://github.com/immich-app/immich/pull/22757#issuecomment-3404516987 --- mobile/scripts/fdroid_build_isar.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/scripts/fdroid_build_isar.sh b/mobile/scripts/fdroid_build_isar.sh index f42bc51d9a..a145268356 100755 --- a/mobile/scripts/fdroid_build_isar.sh +++ b/mobile/scripts/fdroid_build_isar.sh @@ -8,11 +8,11 @@ bash tool/build_android.sh x64 bash tool/build_android.sh armv7 bash tool/build_android.sh arm64 mv libisar_android_arm64.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/ +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/ mv libisar_android_armv7.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/ +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/ mv libisar_android_x64.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/ +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86_64/ mv libisar_android_x86.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/ -) \ No newline at end of file +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86/ +) From b8087b4fa23ee7f0d2c0518aa34c4a3c110823e9 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 3 Nov 2025 10:11:11 -0600 Subject: [PATCH 04/93] chore: ios prod build with correct argument, get version number from pubspec (#23554) * chore: ios prod build with correct argument, get version number from pubspec * Update mobile/ios/fastlane/Fastfile Co-authored-by: bo0tzz --------- Co-authored-by: bo0tzz --- misc/release/pump-version.sh | 1 - mobile/ios/fastlane/Fastfile | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 65a2e70e50..d0db83b946 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -88,7 +88,6 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then fi sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile -sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 8229c65534..c3dfea5f66 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -32,6 +32,17 @@ platform :ios do ) end + # Helper method to get version from pubspec.yaml +def get_version_from_pubspec + require 'yaml' + + pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml") + pubspec = YAML.load_file(pubspec_path) + + version_string = pubspec['version'] + version_string ? version_string.split('+').first : nil +end + # Helper method to configure code signing for all targets def configure_code_signing(bundle_id_suffix: "") bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" @@ -158,7 +169,8 @@ platform :ios do # Build and upload with version number build_and_upload( api_key: api_key, - version_number: "2.1.0" + version_number: get_version_from_pubspec, + distribute_external: false, ) end @@ -168,8 +180,9 @@ platform :ios do path: "./Runner.xcodeproj", targets: ["Runner", "ShareExtension", "WidgetExtension"] ) + increment_version_number( - version_number: "2.2.2" + version_number: get_version_from_pubspec ) increment_build_number( build_number: latest_testflight_build_number + 1, From 0647c22956d46b2c249d55680a6df5781014ae29 Mon Sep 17 00:00:00 2001 From: Sergey Katsubo Date: Tue, 4 Nov 2025 06:09:18 +0300 Subject: [PATCH 05/93] fix(mobile): handle empty original filename (#23469) * Handle empty original filename * Handle TypeError from photo_manager titleAsync * More compact exception log --- mobile/lib/repositories/asset_media.repository.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index e377ff22d6..2e4bdfd32c 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -89,9 +89,16 @@ class AssetMediaRepository { return null; } - // titleAsync gets the correct original filename for some assets on iOS - // otherwise using the `entity.title` would return a random GUID - return await entity.titleAsync; + try { + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + final originalFilename = await entity.titleAsync; + // treat empty filename as missing + return originalFilename.isNotEmpty ? originalFilename : null; + } catch (e) { + _log.warning("Failed to get original filename for asset: $id. Error: $e"); + return null; + } } // TODO: make this more efficient From 1e4779cf4898a6f27949a684c3a5c5dd45bc403f Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Mon, 3 Nov 2025 21:09:32 -0600 Subject: [PATCH 06/93] fix(mobile): ignore patch releases for app version alerts (#23565) * fix(mobile): ignore patch releases for app version alerts * chore: make difference type nullable to indicate when versions match * chore: add error handling for semver parsing * chore: tests --- .../lib/providers/server_info.provider.dart | 2 +- mobile/lib/utils/semver.dart | 30 +++++- mobile/test/utils/semver_test.dart | 92 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 mobile/test/utils/semver_test.dart diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 7a424c332d..9619ba86a1 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -67,7 +67,7 @@ class ServerInfoNotifier extends StateNotifier { return; } - if (clientVersion < serverVersion) { + if (clientVersion < serverVersion && clientVersion.differenceType(serverVersion) != SemVerType.patch) { state = state.copyWith(versionStatus: VersionStatus.clientOutOfDate); return; } diff --git a/mobile/lib/utils/semver.dart b/mobile/lib/utils/semver.dart index 0eb6726b65..aebfd2fe4c 100644 --- a/mobile/lib/utils/semver.dart +++ b/mobile/lib/utils/semver.dart @@ -1,3 +1,5 @@ +enum SemVerType { major, minor, patch } + class SemVer { final int major; final int minor; @@ -15,8 +17,20 @@ class SemVer { } factory SemVer.fromString(String version) { + if (version.toLowerCase().startsWith("v")) { + version = version.substring(1); + } + final parts = version.split("-")[0].split('.'); - return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); + if (parts.length != 3) { + throw FormatException('Invalid semantic version string: $version'); + } + + try { + return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); + } catch (e) { + throw FormatException('Invalid semantic version string: $version'); + } } bool operator >(SemVer other) { @@ -54,6 +68,20 @@ class SemVer { return other is SemVer && other.major == major && other.minor == minor && other.patch == patch; } + SemVerType? differenceType(SemVer other) { + if (major != other.major) { + return SemVerType.major; + } + if (minor != other.minor) { + return SemVerType.minor; + } + if (patch != other.patch) { + return SemVerType.patch; + } + + return null; + } + @override int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; } diff --git a/mobile/test/utils/semver_test.dart b/mobile/test/utils/semver_test.dart new file mode 100644 index 0000000000..8f1958a879 --- /dev/null +++ b/mobile/test/utils/semver_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/semver.dart'; + +void main() { + group('SemVer', () { + test('Parses valid semantic version strings correctly', () { + final version = SemVer.fromString('1.2.3'); + expect(version.major, 1); + expect(version.minor, 2); + expect(version.patch, 3); + }); + + test('Throws FormatException for invalid version strings', () { + expect(() => SemVer.fromString('1.2'), throwsFormatException); + expect(() => SemVer.fromString('a.b.c'), throwsFormatException); + expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException); + }); + + test('Compares equal versons correctly', () { + final v1 = SemVer.fromString('1.2.3'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1 == v2, isTrue); + expect(v1 > v2, isFalse); + expect(v1 < v2, isFalse); + }); + + test('Compares major version correctly', () { + final v1 = SemVer.fromString('2.0.0'); + final v2 = SemVer.fromString('1.9.9'); + expect(v1 == v2, isFalse); + expect(v1 > v2, isTrue); + expect(v1 < v2, isFalse); + }); + + test('Compares minor version correctly', () { + final v1 = SemVer.fromString('1.3.0'); + final v2 = SemVer.fromString('1.2.9'); + expect(v1 == v2, isFalse); + expect(v1 > v2, isTrue); + expect(v1 < v2, isFalse); + }); + + test('Compares patch version correctly', () { + final v1 = SemVer.fromString('1.2.4'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1 == v2, isFalse); + expect(v1 > v2, isTrue); + expect(v1 < v2, isFalse); + }); + + test('Gives correct major difference type', () { + final v1 = SemVer.fromString('2.0.0'); + final v2 = SemVer.fromString('1.9.9'); + expect(v1.differenceType(v2), SemVerType.major); + }); + + test('Gives correct minor difference type', () { + final v1 = SemVer.fromString('1.3.0'); + final v2 = SemVer.fromString('1.2.9'); + expect(v1.differenceType(v2), SemVerType.minor); + }); + + test('Gives correct patch difference type', () { + final v1 = SemVer.fromString('1.2.4'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1.differenceType(v2), SemVerType.patch); + }); + + test('Gives null difference type for equal versions', () { + final v1 = SemVer.fromString('1.2.3'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1.differenceType(v2), isNull); + }); + + test('toString returns correct format', () { + final version = SemVer.fromString('1.2.3'); + expect(version.toString(), '1.2.3'); + }); + + test('Parses versions with leading v correctly', () { + final version1 = SemVer.fromString('v1.2.3'); + expect(version1.major, 1); + expect(version1.minor, 2); + expect(version1.patch, 3); + + final version2 = SemVer.fromString('V1.2.3'); + expect(version2.major, 1); + expect(version2.minor, 2); + expect(version2.patch, 3); + }); + }); +} From 28eb1bc13cfd4642fe90fd8cf724ba7acf94f675 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 03:14:34 +0000 Subject: [PATCH 07/93] chore: version v2.2.3 --- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package.json | 2 +- web/package.json | 2 +- 12 files changed, 16 insertions(+), 12 deletions(-) diff --git a/cli/package.json b/cli/package.json index 0dc0136cf2..49b6767542 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.100", + "version": "2.2.101", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 1d50ae54f8..6affb532c9 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v2.2.3", + "url": "https://docs.v2.2.3.archive.immich.app" + }, { "label": "v2.2.2", "url": "https://docs.v2.2.2.archive.immich.app" diff --git a/e2e/package.json b/e2e/package.json index a6721a1bec..a070f16181 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "2.2.2", + "version": "2.2.3", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 1d36648c3d..a93ab1c2af 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "immich-ml" -version = "2.2.2" +version = "2.2.3" description = "" authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] requires-python = ">=3.10,<4.0" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 1c22a83dc3..6b9ce07465 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3025, - "android.injected.version.name" => "2.2.2", + "android.injected.version.code" => 3026, + "android.injected.version.name" => "2.2.3", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index efde847034..85ec031736 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 2.2.2 +- API version: 2.2.3 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b47038b7e5..617516b94a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 2.2.2+3025 +version: 2.2.3+3026 environment: sdk: '>=3.8.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2b225769a..d16e4c4e10 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10006,7 +10006,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "2.2.2", + "version": "2.2.3", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 9e62898cee..50499278a2 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "2.2.2", + "version": "2.2.3", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f6a68ee8df..435e10046a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 2.2.2 + * 2.2.3 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package.json b/server/package.json index 69fb98d0ed..9ec7351e1f 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "2.2.2", + "version": "2.2.3", "description": "", "author": "", "private": true, diff --git a/web/package.json b/web/package.json index dd582af438..24d489a7d8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "2.2.2", + "version": "2.2.3", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { From cad654586f6d4845752e44d993e51b825617e5fc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:47:54 +0100 Subject: [PATCH 08/93] chore(deps): update dependency @types/node to ^22.18.13 (#23581) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- server/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index 49b6767542..83be681284 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.18.12", + "@types/node": "^22.18.13", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index a070f16181..b94fd7f6e2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^22.18.12", + "@types/node": "^22.18.13", "@types/oidc-provider": "^9.0.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 50499278a2..a751bb7d1b 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.18.12", + "@types/node": "^22.18.13", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6faccf6ab6..463c7ccb46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^22.18.12 + specifier: ^22.18.13 version: 22.18.13 '@vitest/coverage-v8': specifier: ^3.0.0 @@ -211,7 +211,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^22.18.12 + specifier: ^22.18.13 version: 22.18.13 '@types/oidc-provider': specifier: ^9.0.0 @@ -293,7 +293,7 @@ importers: version: 1.0.4 devDependencies: '@types/node': - specifier: ^22.18.12 + specifier: ^22.18.13 version: 22.18.13 typescript: specifier: ^5.3.3 @@ -582,7 +582,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^22.18.12 + specifier: ^22.18.13 version: 22.18.13 '@types/nodemailer': specifier: ^7.0.0 diff --git a/server/package.json b/server/package.json index 9ec7351e1f..5b36efb2c4 100644 --- a/server/package.json +++ b/server/package.json @@ -129,7 +129,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^22.18.12", + "@types/node": "^22.18.13", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From 821a9d4691a97e4dbec7db062d9dd516de252546 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:48:21 +0100 Subject: [PATCH 09/93] chore(deps): update redis:6.2-alpine docker digest to 37e0024 (#23579) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- e2e/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 9aef2288f6..3d62c8a34a 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -35,7 +35,7 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc + image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb database: image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 From f396e9e3744f1ed67a645be31d67bac335553ea7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:49:12 +0100 Subject: [PATCH 10/93] chore(deps): update prom/prometheus docker digest to 4921475 (#23578) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index a8c0de7454..e01f4ead22 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -83,7 +83,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7 + image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From c34be73d817d33721961cb0a6d0ba5299ee968cc Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:12:47 +0100 Subject: [PATCH 11/93] fix(web): consistently use mdiMotionPauseOutline icon (#23595) --- .../asset-viewer/actions/motion-photo-action.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte index 440cdeb2a6..ee09c2976b 100644 --- a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte @@ -1,6 +1,6 @@

{#if assets && data.assets.length > 0} {#each assets as asset (asset.id)} - setAsset(asset)} /> + {/each} {:else}

diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts index 6780fdb023..19754bcff2 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,15 +1,17 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { searchLargeAssets } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ url }) => { +export const load = (async ({ params, url }) => { await authenticate(url); - const assets = await searchLargeAssets({ minFileSize: 0 }); + const [assets, asset] = await Promise.all([searchLargeAssets({ minFileSize: 0 }), getAssetInfoFromParam(params)]); const $t = await getFormatter(); return { assets, + asset, meta: { title: $t('large_files'), }, From a4ae86ce294683d85723b0c11a6c28d32a1be401 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:55:11 -0500 Subject: [PATCH 22/93] feat(ml): add preload and fp16 settings for ocr (#23576) --- docs/docs/install/environment-variables.md | 48 ++++++++++--------- machine-learning/immich_ml/config.py | 9 ++++ machine-learning/immich_ml/main.py | 14 ++++++ machine-learning/immich_ml/schemas.py | 5 ++ machine-learning/immich_ml/sessions/ort.py | 8 ++-- machine-learning/test_main.py | 55 ++++++++++++++++++++-- 6 files changed, 109 insertions(+), 30 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 78a5289bf4..55c226d507 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -149,29 +149,31 @@ Redis (Sentinel) URL example JSON before encoding: ## Machine Learning -| Variable | Description | Default | Containers | -| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- | -| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | -| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | -| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | -| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | -| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | -| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | -| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | -| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning | -| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning | -| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | -| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | -| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | -| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | -| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | -| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning | -| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning | -| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning | +| Variable | Description | Default | Containers | +| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- | +| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | +| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | +| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | +| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | +| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | +| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | +| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | +| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning | +| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | +| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | +| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | +| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | +| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning | +| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning | +| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning | +| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning | +| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. diff --git a/machine-learning/immich_ml/config.py b/machine-learning/immich_ml/config.py index 68d00625a3..19fd5300df 100644 --- a/machine-learning/immich_ml/config.py +++ b/machine-learning/immich_ml/config.py @@ -13,6 +13,8 @@ from rich.logging import RichHandler from uvicorn import Server from uvicorn.workers import UvicornWorker +from .schemas import ModelPrecision + class ClipSettings(BaseModel): textual: str | None = None @@ -24,6 +26,11 @@ class FacialRecognitionSettings(BaseModel): detection: str | None = None +class OcrSettings(BaseModel): + recognition: str | None = None + detection: str | None = None + + class PreloadModelData(BaseModel): clip_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__CLIP", None) facial_recognition_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION", None) @@ -37,6 +44,7 @@ class PreloadModelData(BaseModel): del os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION"] clip: ClipSettings = ClipSettings() facial_recognition: FacialRecognitionSettings = FacialRecognitionSettings() + ocr: OcrSettings = OcrSettings() class MaxBatchSize(BaseModel): @@ -70,6 +78,7 @@ class Settings(BaseSettings): rknn_threads: int = 1 preload: PreloadModelData | None = None max_batch_size: MaxBatchSize | None = None + openvino_precision: ModelPrecision = ModelPrecision.FP32 @property def device_id(self) -> str: diff --git a/machine-learning/immich_ml/main.py b/machine-learning/immich_ml/main.py index 35f04d77ef..3d34d9bf9d 100644 --- a/machine-learning/immich_ml/main.py +++ b/machine-learning/immich_ml/main.py @@ -103,6 +103,20 @@ async def preload_models(preload: PreloadModelData) -> None: ModelTask.FACIAL_RECOGNITION, ) + if preload.ocr.detection is not None: + await load_models( + preload.ocr.detection, + ModelType.DETECTION, + ModelTask.OCR, + ) + + if preload.ocr.recognition is not None: + await load_models( + preload.ocr.recognition, + ModelType.RECOGNITION, + ModelTask.OCR, + ) + if preload.clip_fallback is not None: log.warning( "Deprecated env variable: 'MACHINE_LEARNING_PRELOAD__CLIP'. " diff --git a/machine-learning/immich_ml/schemas.py b/machine-learning/immich_ml/schemas.py index bfb40b9c84..41706180de 100644 --- a/machine-learning/immich_ml/schemas.py +++ b/machine-learning/immich_ml/schemas.py @@ -46,6 +46,11 @@ class ModelSource(StrEnum): PADDLE = "paddle" +class ModelPrecision(StrEnum): + FP16 = "FP16" + FP32 = "FP32" + + ModelIdentity = tuple[ModelType, ModelTask] diff --git a/machine-learning/immich_ml/sessions/ort.py b/machine-learning/immich_ml/sessions/ort.py index b6f709a323..6c52936722 100644 --- a/machine-learning/immich_ml/sessions/ort.py +++ b/machine-learning/immich_ml/sessions/ort.py @@ -93,10 +93,12 @@ class OrtSession: case "CUDAExecutionProvider" | "ROCMExecutionProvider": options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id} case "OpenVINOExecutionProvider": + openvino_dir = self.model_path.parent / "openvino" + device = f"GPU.{settings.device_id}" options = { - "device_type": f"GPU.{settings.device_id}", - "precision": "FP32", - "cache_dir": (self.model_path.parent / "openvino").as_posix(), + "device_type": device, + "precision": settings.openvino_precision.value, + "cache_dir": openvino_dir.as_posix(), } case "CoreMLExecutionProvider": options = { diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index 582a05a950..eb8706fc19 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -26,7 +26,7 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn from immich_ml.models.clip.visual import OpenClipVisualEncoder from immich_ml.models.facial_recognition.detection import FaceDetector from immich_ml.models.facial_recognition.recognition import FaceRecognizer -from immich_ml.schemas import ModelFormat, ModelTask, ModelType +from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType from immich_ml.sessions.ann import AnnSession from immich_ml.sessions.ort import OrtSession from immich_ml.sessions.rknn import RknnSession, run_inference @@ -240,11 +240,16 @@ class TestOrtSession: @pytest.mark.ov_device_ids(["GPU.0", "CPU"]) def test_sets_default_provider_options(self, ov_device_ids: list[str]) -> None: - model_path = "/cache/ViT-B-32__openai/model.onnx" + model_path = "/cache/ViT-B-32__openai/textual/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]) assert session.provider_options == [ - {"device_type": "GPU.0", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"}, + { + "device_type": "GPU.0", + "precision": "FP32", + "cache_dir": "/cache/ViT-B-32__openai/textual/openvino", + }, {"arena_extend_strategy": "kSameAsRequested"}, ] @@ -262,6 +267,21 @@ class TestOrtSession: } ] + def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None: + model_path = "/cache/ViT-B-32__openai/textual/model.onnx" + os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16) + + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.provider_options == [ + { + "device_type": "GPU.1", + "precision": "FP16", + "cache_dir": "/cache/ViT-B-32__openai/textual/openvino", + } + ] + def test_sets_provider_options_for_cuda(self) -> None: os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" @@ -417,7 +437,7 @@ class TestRknnSession: session.run(None, input_feed) rknn_session.return_value.put.assert_called_once_with([input1, input2]) - np_spy.call_count == 2 + assert np_spy.call_count == 2 np_spy.assert_has_calls([mock.call(input1), mock.call(input2)]) @@ -925,11 +945,34 @@ class TestCache: any_order=True, ) + async def test_preloads_ocr_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None: + os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile" + os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile" + + settings = Settings() + assert settings.preload is not None + assert settings.preload.ocr.detection == "PP-OCRv5_mobile" + assert settings.preload.ocr.recognition == "PP-OCRv5_mobile" + + model_cache = ModelCache() + monkeypatch.setattr("immich_ml.main.model_cache", model_cache) + + await preload_models(settings.preload) + mock_get_model.assert_has_calls( + [ + mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR), + mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR), + ], + any_order=True, + ) + async def test_preloads_all_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None: os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai" os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai" os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s" os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s" + os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile" + os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile" settings = Settings() assert settings.preload is not None @@ -937,6 +980,8 @@ class TestCache: assert settings.preload.clip.textual == "ViT-B-32__openai" assert settings.preload.facial_recognition.recognition == "buffalo_s" assert settings.preload.facial_recognition.detection == "buffalo_s" + assert settings.preload.ocr.detection == "PP-OCRv5_mobile" + assert settings.preload.ocr.recognition == "PP-OCRv5_mobile" model_cache = ModelCache() monkeypatch.setattr("immich_ml.main.model_cache", model_cache) @@ -948,6 +993,8 @@ class TestCache: mock.call("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH), mock.call("buffalo_s", ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION), mock.call("buffalo_s", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION), + mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR), + mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR), ], any_order=True, ) From 6913697ad15b3fcad80fc136ecf710af19d1f5df Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:58:41 -0500 Subject: [PATCH 23/93] feat(ml): multilingual ocr (#23527) * handle other languages in ml server * add variants to model selector * no need to override path * unused import --- machine-learning/immich_ml/models/constants.py | 8 ++++++++ machine-learning/immich_ml/models/ocr/detection.py | 2 +- machine-learning/immich_ml/models/ocr/recognition.py | 4 +++- machine-learning/immich_ml/models/ocr/schemas.py | 4 ++-- .../admin-settings/MachineLearningSettings.svelte | 10 ++++++++-- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/machine-learning/immich_ml/models/constants.py b/machine-learning/immich_ml/models/constants.py index 10a4ae48a9..db9e7cfa4d 100644 --- a/machine-learning/immich_ml/models/constants.py +++ b/machine-learning/immich_ml/models/constants.py @@ -78,6 +78,14 @@ _INSIGHTFACE_MODELS = { _PADDLE_MODELS = { "PP-OCRv5_server", "PP-OCRv5_mobile", + "CH__PP-OCRv5_server", + "CH__PP-OCRv5_mobile", + "EL__PP-OCRv5_mobile", + "EN__PP-OCRv5_mobile", + "ESLAV__PP-OCRv5_mobile", + "KOREAN__PP-OCRv5_mobile", + "LATIN__PP-OCRv5_mobile", + "TH__PP-OCRv5_mobile", } SUPPORTED_PROVIDERS = [ diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index 0a9d09b599..07a2f3cce2 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -23,7 +23,7 @@ class TextDetector(InferenceModel): identity = (ModelType.DETECTION, ModelTask.OCR) def __init__(self, model_name: str, **model_kwargs: Any) -> None: - super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX) + super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX) self.max_resolution = 736 self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32) self.std_inv = np.float32(1.0) / (np.array([0.5, 0.5, 0.5], dtype=np.float32) * 255.0) diff --git a/machine-learning/immich_ml/models/ocr/recognition.py b/machine-learning/immich_ml/models/ocr/recognition.py index 0f91fc4105..af3f99dbdb 100644 --- a/machine-learning/immich_ml/models/ocr/recognition.py +++ b/machine-learning/immich_ml/models/ocr/recognition.py @@ -25,6 +25,7 @@ class TextRecognizer(InferenceModel): identity = (ModelType.RECOGNITION, ModelTask.OCR) def __init__(self, model_name: str, **model_kwargs: Any) -> None: + self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH self.min_score = model_kwargs.get("minScore", 0.9) self._empty: TextRecognitionOutput = { "box": np.empty(0, dtype=np.float32), @@ -41,7 +42,7 @@ class TextRecognizer(InferenceModel): engine_type=EngineType.ONNXRUNTIME, ocr_version=OCRVersion.PPOCRV5, task_type=TaskType.REC, - lang_type=LangRec.CH, + lang_type=self.language, model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER, ) ) @@ -61,6 +62,7 @@ class TextRecognizer(InferenceModel): session=session.session, rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6, rec_img_shape=(3, 48, 320), + lang_type=self.language, ) ) return session diff --git a/machine-learning/immich_ml/models/ocr/schemas.py b/machine-learning/immich_ml/models/ocr/schemas.py index a63c8dd8e5..78e8619a0b 100644 --- a/machine-learning/immich_ml/models/ocr/schemas.py +++ b/machine-learning/immich_ml/models/ocr/schemas.py @@ -20,8 +20,8 @@ class TextRecognitionOutput(TypedDict): # RapidOCR expects `engine_type`, `lang_type`, and `font_path` to be attributes class OcrOptions(dict[str, Any]): - def __init__(self, **options: Any) -> None: + def __init__(self, lang_type: LangRec | None = None, **options: Any) -> None: super().__init__(**options) self.engine_type = EngineType.ONNXRUNTIME - self.lang_type = LangRec.CH + self.lang_type = lang_type self.font_path = None diff --git a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte index 7649ee8d17..e05b5088a4 100644 --- a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte +++ b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte @@ -275,8 +275,14 @@ name="ocr-model" bind:value={config.machineLearning.ocr.modelName} options={[ - { value: 'PP-OCRv5_server', text: 'PP-OCRv5_server' }, - { value: 'PP-OCRv5_mobile', text: 'PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_server (Chinese, Japanese and English)', value: 'PP-OCRv5_server' }, + { text: 'PP-OCRv5_mobile (Chinese, Japanese and English)', value: 'PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (English-only)', value: 'EN__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Greek and English)', value: 'EL__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Korean and English)', value: 'KOREAN__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Latin script languages)', value: 'LATIN__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Russian, Belarusian, Ukrainian and English)', value: 'ESLAV__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Thai and English)', value: 'TH__PP-OCRv5_mobile' }, ]} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.ocr.enabled} isEdited={config.machineLearning.ocr.modelName !== savedConfig.machineLearning.ocr.modelName} From 93ab42fa24b0458ece0cb88c8340a04145c26815 Mon Sep 17 00:00:00 2001 From: fabianbees Date: Fri, 7 Nov 2025 18:10:59 +0100 Subject: [PATCH 24/93] feat(mobile): Show lens model information in the asset viewer detail panel (#23601) * feat(mobile): add lens info to details bottom sheet * fix unrelated typo * order same like in web app: first exposure time, than iso --- .../asset_viewer/bottom_sheet.widget.dart | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) 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 9271c99ae9..d29e09a247 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -127,13 +127,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget { if (exifInfo == null) { return null; } - - final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; - final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } - return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + String? _getLensInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); } Future _editDateTime(BuildContext context, WidgetRef ref) async { @@ -141,20 +146,20 @@ class _AssetDetailBottomSheet extends ConsumerWidget { } Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { - final aseet = ref.watch(currentAssetNotifier); - if (aseet == null) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { return const SizedBox.shrink(); } - if (!aseet.hasRemote) { + if (!asset.hasRemote) { return const SizedBox.shrink(); } String? remoteAssetId; - if (aseet is RemoteAsset) { - remoteAssetId = aseet.id; - } else if (aseet is LocalAsset) { - remoteAssetId = aseet.remoteAssetId; + if (asset is RemoteAsset) { + remoteAssetId = asset.id; + } else if (asset is LocalAsset) { + remoteAssetId = asset.remoteAssetId; } if (remoteAssetId == null) { @@ -217,6 +222,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final cameraTitle = _getCameraInfoTitle(exifInfo); + final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); // Build file info tile based on asset type @@ -287,12 +293,23 @@ class _AssetDetailBottomSheet extends ConsumerWidget { _SheetTile( title: cameraTitle, titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), subtitle: _getCameraInfoSubtitle(exifInfo), subtitleStyle: context.textTheme.bodyMedium?.copyWith( color: context.textTheme.bodyMedium?.color?.withAlpha(155), ), ), + // Lens info + if (lensTitle != null) + _SheetTile( + title: lensTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getLensInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith( + color: context.textTheme.bodyMedium?.color?.withAlpha(155), + ), + ), // Appears in (Albums) _buildAppearsInList(ref, context), // padding at the bottom to avoid cut-off From c935ae47d0b1576c080b6f5c61d27aa73da874bf Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 7 Nov 2025 21:22:02 +0100 Subject: [PATCH 25/93] feat: lazy load thumbnails on people and place list (#23682) perf(web): lazy load thumbnails on people and place list --- web/src/lib/components/assets/thumbnail/image-thumbnail.svelte | 3 +++ .../lib/components/faces-page/manage-people-visibility.svelte | 1 + web/src/lib/components/faces-page/people-card.svelte | 1 + web/src/lib/components/places-page/places-card-group.svelte | 1 + 4 files changed, 6 insertions(+) diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index d73e9b8218..2b55ea57b2 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -20,6 +20,7 @@ hiddenIconClass?: string; class?: ClassValue; brokenAssetClass?: ClassValue; + preload?: boolean; onComplete?: ((errored: boolean) => void) | undefined; } @@ -38,6 +39,7 @@ onComplete = undefined, class: imageClass = '', brokenAssetClass = '', + preload = true, }: Props = $props(); let loaded = $state(false); @@ -92,6 +94,7 @@ {title} class={['object-cover', optionalClasses, imageClass]} draggable="false" + loading={preload ? 'eager' : 'lazy'} /> {/if} diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte index 8a3dcd98df..5771766f64 100644 --- a/web/src/lib/components/faces-page/manage-people-visibility.svelte +++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte @@ -157,6 +157,7 @@ altText={person.name} widthStyle="100%" hiddenIconClass="text-white group-hover:text-black transition-colors" + preload={false} /> {#if person.name} diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 3d865223ca..e18109de0d 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -52,6 +52,7 @@ title={person.name} widthStyle="100%" circle + preload={false} /> {#if person.isFavorite}

diff --git a/web/src/lib/components/places-page/places-card-group.svelte b/web/src/lib/components/places-page/places-card-group.svelte index 2e918b79ff..19e230d695 100644 --- a/web/src/lib/components/places-page/places-card-group.svelte +++ b/web/src/lib/components/places-page/places-card-group.svelte @@ -49,6 +49,7 @@ src={getAssetThumbnailUrl({ id: item.id, size: AssetMediaSize.Thumbnail })} alt={city} class="object-cover w-39 h-39" + loading="lazy" />
Date: Fri, 7 Nov 2025 15:13:43 -0800 Subject: [PATCH 26/93] fix(mobile): Add fade-in to asset viewer transition (#23692) Add fade-in animation --- mobile/lib/routing/router.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 5c0299c414..abe7ac3fa2 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -313,6 +313,7 @@ class AppRouter extends RootStackRouter { settings: page, pageBuilder: (_, __, ___) => child, opaque: false, + transitionsBuilder: TransitionsBuilders.fadeIn, ), ), ), From 4905bba69404098c538e5e708777f6a45753a964 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 8 Nov 2025 13:48:35 -0600 Subject: [PATCH 27/93] chore(deps): update base-image to v202511041104 (major) (#23718) chore(deps): update base-image to v202511041104 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- server/Dockerfile.dev | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 54077d80ce..0fc4126926 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS builder +FROM ghcr.io/immich-app/base-server-dev:202511041104@sha256:7558931a4a71989e7fd9fa3e1ba6c28da15891867310edda8c58236171839f2f AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -48,7 +48,7 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ pnpm --filter @immich/sdk --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned -FROM ghcr.io/immich-app/base-server-prod:202510281104@sha256:84f8f3eb4cfafc5e624235f7db703e1222fd60831bef1d488d8d8cad2be5023d +FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 93a4f197ea..133b8a835d 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS dev +FROM ghcr.io/immich-app/base-server-dev:202511041104@sha256:7558931a4a71989e7fd9fa3e1ba6c28da15891867310edda8c58236171839f2f AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ From 9cc88ed2a6df8dbd3cda2f6d38c2a03524761023 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Sat, 8 Nov 2025 23:46:43 +0100 Subject: [PATCH 28/93] feat: make memories slideshow duration configurable (#22783) --- i18n/en.json | 1 + .../openapi/lib/model/memories_response.dart | 10 +++++++++- mobile/openapi/lib/model/memories_update.dart | 20 ++++++++++++++++++- open-api/immich-openapi-specs.json | 9 +++++++++ open-api/typescript-sdk/src/fetch-client.ts | 2 ++ server/src/dtos/user-preferences.dto.ts | 9 +++++++++ server/src/types.ts | 1 + server/src/utils/preferences.ts | 1 + .../memory-page/memory-viewer.svelte | 2 +- .../feature-settings.svelte | 13 +++++++++++- .../factories/preferences-factory.ts | 1 + 11 files changed, 65 insertions(+), 4 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 30c8949aef..735e942dda 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2027,6 +2027,7 @@ "third_party_resources": "Third-Party Resources", "time": "Time", "time_based_memories": "Time-based memories", + "time_based_memories_duration": "Number of seconds to display each image.", "timeline": "Timeline", "timezone": "Timezone", "to_archive": "Archive", diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart index b9f8b5d8b1..cb42f596a6 100644 --- a/mobile/openapi/lib/model/memories_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -13,25 +13,31 @@ part of openapi.api; class MemoriesResponse { /// Returns a new [MemoriesResponse] instance. MemoriesResponse({ + this.duration = 5, this.enabled = true, }); + int duration; + bool enabled; @override bool operator ==(Object other) => identical(this, other) || other is MemoriesResponse && + other.duration == duration && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis + (duration.hashCode) + (enabled.hashCode); @override - String toString() => 'MemoriesResponse[enabled=$enabled]'; + String toString() => 'MemoriesResponse[duration=$duration, enabled=$enabled]'; Map toJson() { final json = {}; + json[r'duration'] = this.duration; json[r'enabled'] = this.enabled; return json; } @@ -45,6 +51,7 @@ class MemoriesResponse { final json = value.cast(); return MemoriesResponse( + duration: mapValueOfType(json, r'duration')!, enabled: mapValueOfType(json, r'enabled')!, ); } @@ -93,6 +100,7 @@ class MemoriesResponse { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'duration', 'enabled', }; } diff --git a/mobile/openapi/lib/model/memories_update.dart b/mobile/openapi/lib/model/memories_update.dart index 71efd71ae7..39c46ffd2f 100644 --- a/mobile/openapi/lib/model/memories_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -13,9 +13,19 @@ part of openapi.api; class MemoriesUpdate { /// Returns a new [MemoriesUpdate] instance. MemoriesUpdate({ + this.duration, this.enabled, }); + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + int? duration; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -26,18 +36,25 @@ class MemoriesUpdate { @override bool operator ==(Object other) => identical(this, other) || other is MemoriesUpdate && + other.duration == duration && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis + (duration == null ? 0 : duration!.hashCode) + (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'MemoriesUpdate[enabled=$enabled]'; + String toString() => 'MemoriesUpdate[duration=$duration, enabled=$enabled]'; Map toJson() { final json = {}; + if (this.duration != null) { + json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } if (this.enabled != null) { json[r'enabled'] = this.enabled; } else { @@ -55,6 +72,7 @@ class MemoriesUpdate { final json = value.cast(); return MemoriesUpdate( + duration: mapValueOfType(json, r'duration'), enabled: mapValueOfType(json, r'enabled'), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d16e4c4e10..6076b43bfd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12646,18 +12646,27 @@ }, "MemoriesResponse": { "properties": { + "duration": { + "default": 5, + "type": "integer" + }, "enabled": { "default": true, "type": "boolean" } }, "required": [ + "duration", "enabled" ], "type": "object" }, "MemoriesUpdate": { "properties": { + "duration": { + "minimum": 1, + "type": "integer" + }, "enabled": { "type": "boolean" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 435e10046a..1ed65e4f25 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -152,6 +152,7 @@ export type FoldersResponse = { sidebarWeb: boolean; }; export type MemoriesResponse = { + duration: number; enabled: boolean; }; export type PeopleResponse = { @@ -209,6 +210,7 @@ export type FoldersUpdate = { sidebarWeb?: boolean; }; export type MemoriesUpdate = { + duration?: number; enabled?: boolean; }; export type PeopleUpdate = { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index b258158ae2..452384b423 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -13,6 +13,12 @@ class AvatarUpdate { class MemoriesUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; + + @Optional() + @IsInt() + @IsPositive() + @ApiProperty({ type: 'integer' }) + duration?: number; } class RatingsUpdate { @@ -166,6 +172,9 @@ class RatingsResponse { class MemoriesResponse { enabled: boolean = true; + + @ApiProperty({ type: 'integer' }) + duration: number = 5; } class FoldersResponse { diff --git a/server/src/types.ts b/server/src/types.ts index 66045521d0..0a2dd46d7f 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -497,6 +497,7 @@ export interface UserPreferences { }; memories: { enabled: boolean; + duration: number; }; people: { enabled: boolean; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 121bf2826d..b25369670a 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -16,6 +16,7 @@ const getDefaultPreferences = (): UserPreferences => { }, memories: { enabled: true, + duration: 5, }, people: { enabled: true, diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index fe3569f916..cfe11e1026 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -102,7 +102,7 @@ }); } else { progressBarController = new Tween(0, { - duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), + duration: (from: number, to: number) => (to ? $preferences.memories.duration * 1000 * (to - from) : 0), }); } }; diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte index 31a29be975..82a40161b5 100644 --- a/web/src/lib/components/user-settings-page/feature-settings.svelte +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -1,7 +1,9 @@ diff --git a/web/src/lib/managers/auth-manager.svelte.ts b/web/src/lib/managers/auth-manager.svelte.ts index 0128892c3f..515fde0996 100644 --- a/web/src/lib/managers/auth-manager.svelte.ts +++ b/web/src/lib/managers/auth-manager.svelte.ts @@ -30,7 +30,7 @@ class AuthManager { globalThis.location.href = redirectUri; } } finally { - eventManager.emit('auth.logout'); + eventManager.emit('AuthLogout'); } } } diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index f8e39411cf..7c7717a4d4 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,6 +1,15 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; import type { LoginResponseDto } from '@immich/sdk'; +export type Events = { + AppInit: []; + UserLogin: []; + AuthLogin: [LoginResponseDto]; + AuthLogout: []; + LanguageChange: [{ name: string; code: string; rtl?: boolean }]; + ThemeChange: [ThemeSetting]; +}; + type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; class EventManager> { @@ -51,11 +60,4 @@ class EventManager> { } } -export const eventManager = new EventManager<{ - 'app.init': []; - 'user.login': []; - 'auth.login': [LoginResponseDto]; - 'auth.logout': []; - 'language.change': [{ name: string; code: string; rtl?: boolean }]; - 'theme.change': [ThemeSetting]; -}>(); +export const eventManager = new EventManager(); diff --git a/web/src/lib/managers/language-manager.svelte.ts b/web/src/lib/managers/language-manager.svelte.ts index 5acae27aa3..91d621f679 100644 --- a/web/src/lib/managers/language-manager.svelte.ts +++ b/web/src/lib/managers/language-manager.svelte.ts @@ -4,7 +4,7 @@ import { lang } from '$lib/stores/preferences.store'; class LanguageManager { constructor() { - eventManager.on('app.init', () => lang.subscribe((lang) => this.setLanguage(lang))); + eventManager.on('AppInit', () => lang.subscribe((lang) => this.setLanguage(lang))); } rtl = $state(false); @@ -19,7 +19,7 @@ class LanguageManager { document.body.setAttribute('dir', item.rtl ? 'rtl' : 'ltr'); - eventManager.emit('language.change', item); + eventManager.emit('LanguageChange', item); } } diff --git a/web/src/lib/managers/theme-manager.svelte.ts b/web/src/lib/managers/theme-manager.svelte.ts index 394c9850de..5095b5739e 100644 --- a/web/src/lib/managers/theme-manager.svelte.ts +++ b/web/src/lib/managers/theme-manager.svelte.ts @@ -36,7 +36,7 @@ class ThemeManager { isDark = $derived(this.value === Theme.DARK); constructor() { - eventManager.on('app.init', () => this.#onAppInit()); + eventManager.on('AppInit', () => this.#onAppInit()); } setSystem(system: boolean) { @@ -71,7 +71,7 @@ class ThemeManager { this.#theme.current = theme; - eventManager.emit('theme.change', theme); + eventManager.emit('ThemeChange', theme); } } diff --git a/web/src/lib/managers/upload-manager.svelte.ts b/web/src/lib/managers/upload-manager.svelte.ts index 61c6d73b53..b51756678b 100644 --- a/web/src/lib/managers/upload-manager.svelte.ts +++ b/web/src/lib/managers/upload-manager.svelte.ts @@ -6,7 +6,7 @@ class UploadManager { mediaTypes = $state({ image: [], sidecar: [], video: [] }); constructor() { - eventManager.on('app.init', () => void this.#loadExtensions()).on('auth.logout', () => void this.reset()); + eventManager.on('AppInit', () => void this.#loadExtensions()).on('AuthLogout', () => void this.reset()); } reset() { diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts index f77b67bb7c..3480e95f28 100644 --- a/web/src/lib/stores/folders.svelte.ts +++ b/web/src/lib/stores/folders.svelte.ts @@ -19,7 +19,7 @@ class FoldersStore { private assets = $state({}); constructor() { - eventManager.on('auth.logout', () => this.clearCache()); + eventManager.on('AuthLogout', () => this.clearCache()); } async fetchTree(): Promise { diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index bc42053a21..05f45b3d7d 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -21,7 +21,7 @@ export type MemoryAsset = MemoryIndex & { class MemoryStoreSvelte { constructor() { - eventManager.on('auth.logout', () => this.clearCache()); + eventManager.on('AuthLogout', () => this.clearCache()); } memories = $state([]); diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts index 3eba15deed..a0f0f6bb93 100644 --- a/web/src/lib/stores/notification-manager.svelte.ts +++ b/web/src/lib/stores/notification-manager.svelte.ts @@ -9,8 +9,8 @@ class NotificationStore { notifications = $state([]); constructor() { - eventManager.on('auth.login', () => handlePromiseError(this.refresh())); - eventManager.on('auth.logout', () => this.clear()); + eventManager.on('AuthLogin', () => handlePromiseError(this.refresh())); + eventManager.on('AuthLogout', () => this.clear()); } async refresh() { diff --git a/web/src/lib/stores/search.svelte.ts b/web/src/lib/stores/search.svelte.ts index 32f2741955..007c764fcf 100644 --- a/web/src/lib/stores/search.svelte.ts +++ b/web/src/lib/stores/search.svelte.ts @@ -5,7 +5,7 @@ class SearchStore { isSearchEnabled = $state(false); constructor() { - eventManager.on('auth.logout', () => this.clearCache()); + eventManager.on('AuthLogout', () => this.clearCache()); } clearCache() { diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 0790788278..bc23917d22 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -16,4 +16,4 @@ export const resetSavedUser = () => { purchaseStore.setPurchaseStatus(false); }; -eventManager.on('auth.logout', () => resetSavedUser()); +eventManager.on('AuthLogout', () => resetSavedUser()); diff --git a/web/src/lib/stores/user.svelte.ts b/web/src/lib/stores/user.svelte.ts index 94d73efb9c..f9319fcfa1 100644 --- a/web/src/lib/stores/user.svelte.ts +++ b/web/src/lib/stores/user.svelte.ts @@ -26,4 +26,4 @@ const reset = () => { Object.assign(userInteraction, defaultUserInteraction); }; -eventManager.on('auth.logout', () => reset()); +eventManager.on('AuthLogout', () => reset()); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 979c7b5c42..f5d4619943 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -58,7 +58,7 @@ // if the browser theme changes, changes the Immich theme too }); - eventManager.emit('app.init'); + eventManager.emit('AppInit'); beforeNavigate(({ from, to }) => { if (isAssetViewerRoute(from) && isAssetViewerRoute(to)) { diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 9ed89a0c63..352eaed408 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -27,7 +27,7 @@ const onSuccess = async (user: LoginResponseDto) => { await goto(data.continueUrl, { invalidateAll: true }); - eventManager.emit('auth.login', user); + eventManager.emit('AuthLogin', user); }; const onFirstLogin = () => goto(AppRoute.AUTH_CHANGE_PASSWORD); From a4e65a7ea822e9ce406e2a9dd0aca4e1acd92d74 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 10 Nov 2025 11:49:59 -0500 Subject: [PATCH 37/93] refactor: albums-list (#23765) --- .../components/album-page/albums-list.svelte | 139 +++++++----------- web/src/lib/services/album.service.ts | 15 ++ web/src/lib/utils/album-utils.ts | 14 -- .../[[assetId=id]]/+page.svelte | 5 +- 4 files changed, 74 insertions(+), 99 deletions(-) diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index 5a93dd08f1..deb206ca9f 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -11,7 +11,7 @@ import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; - import { handleDownloadAlbum } from '$lib/services/album.service'; + import { handleConfirmAlbumDelete, handleDownloadAlbum } from '$lib/services/album.service'; import { AlbumFilter, AlbumGroupBy, @@ -24,13 +24,7 @@ import { user } from '$lib/stores/user.store'; import { userInteraction } from '$lib/stores/user.svelte'; import { makeSharedLinkUrl } from '$lib/utils'; - import { - confirmAlbumDelete, - getSelectedAlbumGroupOption, - sortAlbums, - stringToSortOrder, - type AlbumGroup, - } from '$lib/utils/album-utils'; + import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils'; import type { ContextMenuPosition } from '$lib/utils/context-menu'; import { handleError } from '$lib/utils/handle-error'; import { normalizeSearchString } from '$lib/utils/string-utils'; @@ -142,10 +136,9 @@ let albumGroupOption: string = $state(AlbumGroupBy.None); let albumToShare: AlbumResponseDto | null = $state(null); - let albumToDelete: AlbumResponseDto | null = null; let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 }); - let contextMenuTargetAlbum: AlbumResponseDto | undefined = $state(); + let selectedAlbum: AlbumResponseDto | undefined = $state(); let isOpen = $state(false); // Step 1: Filter between Owned and Shared albums, or both. @@ -198,9 +191,7 @@ albumGroupIds = groupedAlbums.map(({ id }) => id); }); - let showFullContextMenu = $derived( - allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id, - ); + let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === $user.id); onMount(async () => { if (allowEdit) { @@ -209,7 +200,7 @@ }); const showAlbumContextMenu = (contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto) => { - contextMenuTargetAlbum = album; + selectedAlbum = album; contextMenuPosition = { x: contextMenuDetail.x, y: contextMenuDetail.y, @@ -221,13 +212,6 @@ isOpen = false; }; - const onDownloadAlbum = async () => { - if (contextMenuTargetAlbum) { - closeAlbumContextMenu(); - await handleDownloadAlbum(contextMenuTargetAlbum); - } - }; - const handleDeleteAlbum = async (albumToDelete: AlbumResponseDto) => { try { await deleteAlbum({ @@ -247,39 +231,61 @@ sharedAlbums = sharedAlbums.filter(({ id }) => id !== albumToDelete.id); }; - const setAlbumToDelete = async () => { - albumToDelete = contextMenuTargetAlbum ?? null; + const handleSelect = async (action: 'edit' | 'share' | 'download' | 'delete') => { closeAlbumContextMenu(); - await deleteSelectedAlbum(); - }; - const handleEdit = async (album: AlbumResponseDto) => { - closeAlbumContextMenu(); - const editedAlbum = await modalManager.show(AlbumEditModal, { - album, - }); - if (editedAlbum) { - successEditAlbumInfo(editedAlbum); - } - }; - - const deleteSelectedAlbum = async () => { - if (!albumToDelete) { + if (!selectedAlbum) { return; } - const isConfirmed = await confirmAlbumDelete(albumToDelete); + switch (action) { + case 'edit': { + const editedAlbum = await modalManager.show(AlbumEditModal, { album: selectedAlbum }); + if (editedAlbum) { + successEditAlbumInfo(editedAlbum); + } + break; + } - if (!isConfirmed) { - return; - } + case 'share': { + const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum }); + switch (result?.action) { + case 'sharedUsers': { + await handleAddUsers(result.data); + break; + } - try { - await handleDeleteAlbum(albumToDelete); - } catch (error) { - handleError(error, $t('errors.unable_to_delete_album')); - } finally { - albumToDelete = null; + case 'sharedLink': { + const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id }); + if (sharedLink) { + handleSharedLinkCreated(selectedAlbum); + await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); + } + break; + } + } + break; + } + + case 'download': { + await handleDownloadAlbum(selectedAlbum); + break; + } + + case 'delete': { + const isConfirmed = await handleConfirmAlbumDelete(selectedAlbum); + if (!isConfirmed) { + return; + } + + try { + await handleDeleteAlbum(selectedAlbum); + } catch (error) { + handleError(error, $t('errors.unable_to_delete_album')); + } + + break; + } } }; @@ -347,33 +353,6 @@ album.hasSharedLink = true; updateAlbumInfo(album); }; - - const openShareModal = async () => { - if (!contextMenuTargetAlbum) { - return; - } - - albumToShare = contextMenuTargetAlbum; - closeAlbumContextMenu(); - const result = await modalManager.show(AlbumShareModal, { album: albumToShare }); - - switch (result?.action) { - case 'sharedUsers': { - await handleAddUsers(result.data); - return; - } - - case 'sharedLink': { - const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: albumToShare.id }); - - if (sharedLink) { - handleSharedLinkCreated(albumToShare); - await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); - } - return; - } - } - }; {#if albums.length > 0} @@ -411,15 +390,11 @@ {#if showFullContextMenu} - contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)} - /> - openShareModal()} /> + handleSelect('edit')} /> + handleSelect('share')} /> {/if} - + handleSelect('download')} /> {#if showFullContextMenu} - setAlbumToDelete()} /> + handleSelect('delete')} /> {/if} diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index cb7b55bc11..52fa09d103 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -1,6 +1,21 @@ import { downloadArchive } from '$lib/utils/asset-utils'; +import { getFormatter } from '$lib/utils/i18n'; import type { AlbumResponseDto } from '@immich/sdk'; +import { modalManager } from '@immich/ui'; export const handleDownloadAlbum = async (album: AlbumResponseDto) => { await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }); }; + +export const handleConfirmAlbumDelete = async (album: AlbumResponseDto) => { + const $t = await getFormatter(); + const confirmation = + album.albumName.length > 0 + ? $t('album_delete_confirmation', { values: { album: album.albumName } }) + : $t('unnamed_album_delete_confirmation'); + + const description = $t('album_delete_confirmation_description'); + const prompt = `${confirmation} ${description}`; + + return modalManager.showDialog({ prompt }); +}; diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index 0cb8b7fc04..d4541949ca 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -12,7 +12,6 @@ import { import { handleError } from '$lib/utils/handle-error'; import type { AlbumResponseDto } from '@immich/sdk'; import * as sdk from '@immich/sdk'; -import { modalManager } from '@immich/ui'; import { orderBy } from 'lodash-es'; import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; @@ -203,19 +202,6 @@ export const expandAllAlbumGroups = () => { collapseAllAlbumGroups([]); }; -export const confirmAlbumDelete = async (album: AlbumResponseDto) => { - const $t = get(t); - const confirmation = - album.albumName.length > 0 - ? $t('album_delete_confirmation', { values: { album: album.albumName } }) - : $t('unnamed_album_delete_confirmation'); - - const description = $t('album_delete_confirmation_description'); - const prompt = `${confirmation} ${description}`; - - return modalManager.showDialog({ prompt }); -}; - interface AlbumSortOption { [option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumResponseDto[]; } 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 f9453c41cf..34f3240e84 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 @@ -36,14 +36,13 @@ import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; - import { handleDownloadAlbum } from '$lib/services/album.service'; + import { handleConfirmAlbumDelete, handleDownloadAlbum } from '$lib/services/album.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { preferences, user } from '$lib/stores/user.store'; import { handlePromiseError, makeSharedLinkUrl } from '$lib/utils'; - import { confirmAlbumDelete } from '$lib/utils/album-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; @@ -235,7 +234,7 @@ }; const handleRemoveAlbum = async () => { - const isConfirmed = await confirmAlbumDelete(album); + const isConfirmed = await handleConfirmAlbumDelete(album); if (!isConfirmed) { viewMode = AlbumPageViewMode.VIEW; From 45304f121173083587c6db99b89c62c3c6a5b5a4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 10 Nov 2025 12:21:26 -0500 Subject: [PATCH 38/93] refactor: view shared link (#23766) --- .../album-page/album-shared-link.svelte | 8 ++--- .../components/album-page/albums-list.svelte | 5 ++- .../asset-viewer/actions/share-action.svelte | 6 ++-- .../actions/shared-link-copy.svelte | 14 +++----- .../covers/__tests__/share-cover.spec.ts | 10 +++--- .../covers/share-cover.svelte | 16 ++++----- .../sharedlinks-page/shared-link-card.svelte | 36 +++++++++---------- .../actions/CreateSharedLinkAction.svelte | 5 ++- web/src/lib/modals/AlbumShareModal.svelte | 13 ++----- web/src/lib/services/shared-link.service.ts | 21 +++++++++++ web/src/lib/utils.ts | 6 ---- .../[[assetId=id]]/+page.svelte | 6 ++-- .../shared-links/[[id=id]]/+page.svelte | 4 +-- 13 files changed, 74 insertions(+), 76 deletions(-) create mode 100644 web/src/lib/services/shared-link.service.ts diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte index e7d6503da3..935fa7837c 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -1,5 +1,6 @@ diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte index 235048d35d..06369e7792 100644 --- a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte +++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte @@ -1,25 +1,21 @@ {#if menuItem} - + handleCopySharedLinkUrl(sharedLink)} /> {:else} handleCopySharedLinkUrl(sharedLink)} /> {/if} diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts index 76de04ea31..3b8d90f1ba 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts @@ -10,7 +10,7 @@ vi.mock('$lib/utils'); describe('ShareCover component', () => { it('renders an image when the shared link is an album', () => { const component = render(ShareCover, { - link: sharedLinkFactory.build({ album: albumFactory.build({ albumName: '123' }) }), + sharedLink: sharedLinkFactory.build({ album: albumFactory.build({ albumName: '123' }) }), preload: false, class: 'text', }); @@ -23,7 +23,7 @@ describe('ShareCover component', () => { it('renders an image when the shared link is an individual share', () => { vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf'); const component = render(ShareCover, { - link: sharedLinkFactory.build({ assets: [assetFactory.build({ id: 'someId' })] }), + sharedLink: sharedLinkFactory.build({ assets: [assetFactory.build({ id: 'someId' })] }), preload: false, class: 'text', }); @@ -37,7 +37,7 @@ describe('ShareCover component', () => { it('renders an image when the shared link has no album or assets', () => { const component = render(ShareCover, { - link: sharedLinkFactory.build(), + sharedLink: sharedLinkFactory.build(), preload: false, class: 'text', }); @@ -48,9 +48,9 @@ describe('ShareCover component', () => { }); it.skip('renders fallback image when asset is not resized', () => { - const link = sharedLinkFactory.build({ assets: [assetFactory.build()] }); + const sharedLink = sharedLinkFactory.build({ assets: [assetFactory.build()] }); render(ShareCover, { - link, + sharedLink, preload: false, }); diff --git a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte index 6f15cca45f..ec3deb9943 100644 --- a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte @@ -1,29 +1,29 @@
- {#if link?.album} - - {:else if link.assets[0]} + {#if sharedLink?.album} + + {:else if sharedLink.assets[0]} {:else} diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 33842e3f74..f811f60e77 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -13,14 +13,14 @@ import { t } from 'svelte-i18n'; interface Props { - link: SharedLinkResponseDto; + sharedLink: SharedLinkResponseDto; onDelete: () => void; } - let { link, onDelete }: Props = $props(); + let { sharedLink, onDelete }: Props = $props(); let now = DateTime.now(); - let expiresAt = $derived(link.expiresAt ? DateTime.fromISO(link.expiresAt) : undefined); + let expiresAt = $derived(sharedLink.expiresAt ? DateTime.fromISO(sharedLink.expiresAt) : undefined); let isExpired = $derived(expiresAt ? now > expiresAt : false); const getCountDownExpirationDate = (expiresAtDate: DateTime, now: DateTime) => { @@ -41,10 +41,10 @@ > - +
@@ -62,34 +62,34 @@

- {#if link.type === SharedLinkType.Album} - {link.album?.albumName} - {:else if link.type === SharedLinkType.Individual} + {#if sharedLink.type === SharedLinkType.Album} + {sharedLink.album?.albumName} + {:else if sharedLink.type === SharedLinkType.Individual} {$t('individual_share')} {/if}

-

{link.description ?? ''}

+

{sharedLink.description ?? ''}

- {#if link.allowUpload} + {#if sharedLink.allowUpload} {$t('upload')} {/if} - {#if link.allowDownload} + {#if sharedLink.allowDownload} {$t('download')} {/if} - {#if link.showMetadata} + {#if sharedLink.showMetadata} {$t('exif')} {/if} - {#if link.password} + {#if sharedLink.password} {$t('password')} {/if} - {#if link.slug} + {#if sharedLink.slug} {$t('custom_url')} {/if}
@@ -97,8 +97,8 @@
@@ -110,8 +110,8 @@ size="large" hideContent > - - + +
diff --git a/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte b/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte index 515fa64af1..06291e2188 100644 --- a/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte +++ b/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte @@ -1,8 +1,7 @@ diff --git a/web/src/lib/modals/AlbumShareModal.svelte b/web/src/lib/modals/AlbumShareModal.svelte index 9a60c0f710..8a4995efc4 100644 --- a/web/src/lib/modals/AlbumShareModal.svelte +++ b/web/src/lib/modals/AlbumShareModal.svelte @@ -2,8 +2,6 @@ import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte'; import { AppRoute } from '$lib/constants'; import Dropdown from '$lib/elements/Dropdown.svelte'; - import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; - import { makeSharedLinkUrl } from '$lib/utils'; import { AlbumUserRole, getAllSharedLinks, @@ -13,7 +11,7 @@ type SharedLinkResponseDto, type UserResponseDto, } from '@immich/sdk'; - import { Button, Icon, Link, Modal, ModalBody, modalManager, Stack, Text } from '@immich/ui'; + import { Button, Icon, Link, Modal, ModalBody, Stack, Text } from '@immich/ui'; import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -29,13 +27,6 @@ let users: UserResponseDto[] = $state([]); let selectedUsers: Record = $state({}); - const handleViewQrCode = async (sharedLink: SharedLinkResponseDto) => { - await modalManager.show(QrCodeModal, { - title: $t('view_link'), - value: makeSharedLinkUrl(sharedLink), - }); - }; - const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [ { title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil }, { title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye }, @@ -174,7 +165,7 @@ {#each sharedLinks as sharedLink (sharedLink.id)} - handleViewQrCode(sharedLink)} /> + {/each} {/if} diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts new file mode 100644 index 0000000000..35bde6784f --- /dev/null +++ b/web/src/lib/services/shared-link.service.ts @@ -0,0 +1,21 @@ +import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; +import { serverConfig } from '$lib/stores/server-config.store'; +import { copyToClipboard } from '$lib/utils'; +import { getFormatter } from '$lib/utils/i18n'; +import type { SharedLinkResponseDto } from '@immich/sdk'; +import { modalManager } from '@immich/ui'; +import { get } from 'svelte/store'; + +const makeSharedLinkUrl = (sharedLink: SharedLinkResponseDto) => { + const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`; + return new URL(path, get(serverConfig).externalDomain || globalThis.location.origin).href; +}; + +export const handleViewSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) => { + const $t = await getFormatter(); + await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); +}; + +export const handleCopySharedLinkUrl = async (sharedLink: SharedLinkResponseDto) => { + await copyToClipboard(makeSharedLinkUrl(sharedLink)); +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 3f2d945f39..65510352c1 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,7 +1,6 @@ import { defaultLang, langs, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { lang } from '$lib/stores/preferences.store'; -import { serverConfig } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; import { AssetJobName, @@ -269,11 +268,6 @@ export const copyToClipboard = async (secret: string) => { } }; -export const makeSharedLinkUrl = (sharedLink: SharedLinkResponseDto) => { - const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`; - return new URL(path, get(serverConfig).externalDomain || globalThis.location.origin).href; -}; - export const oauth = { isCallback: (location: Location) => { const search = location.search; 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 34f3240e84..1c8a6ccfa0 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 @@ -34,15 +34,15 @@ import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte'; - import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { handleConfirmAlbumDelete, handleDownloadAlbum } from '$lib/services/album.service'; + import { handleViewSharedLinkQrCode } from '$lib/services/shared-link.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { preferences, user } from '$lib/stores/user.store'; - import { handlePromiseError, makeSharedLinkUrl } from '$lib/utils'; + import { handlePromiseError } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; @@ -388,7 +388,7 @@ const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id }); if (sharedLink) { await refreshAlbum(); - await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); + await handleViewSharedLinkQrCode(sharedLink); } }; diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 02f230d609..2aa24a57ba 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -109,8 +109,8 @@
{:else}
- {#each filteredSharedLinks as link (link.id)} - handleDeleteLink(link.id)} /> + {#each filteredSharedLinks as sharedLink (sharedLink.id)} + handleDeleteLink(sharedLink.id)} /> {/each}
{/if} From da5a72f6de894629de2c57424bae12d4428c95d0 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:07:45 +0530 Subject: [PATCH 39/93] chore: patch MemoriesResponse (#23764) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/utils/openapi_patching.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 0a3fa7e91d..0c1f03086f 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -51,6 +51,11 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'ocr', false); } break; + case 'MemoriesResponse': + if (value is Map) { + addDefault(value, 'duration', 5); + } + break; } } From b2cbefe41e3076d51fe94db5c321134be517d74d Mon Sep 17 00:00:00 2001 From: Viktor Mykhailiv Date: Mon, 10 Nov 2025 18:03:12 +0000 Subject: [PATCH 40/93] fix(mobile): Set dynamic height of actions row in BottomSheet (#23755) Co-authored-by: Alex --- .../widgets/bottom_sheet/base_bottom_sheet.widget.dart | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart index 7205dad941..2f2847543f 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart @@ -86,13 +86,9 @@ class _BaseDraggableScrollableSheetState extends ConsumerState SliverToBoxAdapter( child: Column( children: [ - SizedBox( - height: 115, - child: ListView( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - children: widget.actions, - ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: widget.actions), ), const Divider(indent: 16, endIndent: 16), const SizedBox(height: 16), From d6307b262f1be23d677d4a4e87bd3a00560fcd46 Mon Sep 17 00:00:00 2001 From: Noel S Date: Mon, 10 Nov 2025 10:13:04 -0800 Subject: [PATCH 41/93] fix(mobile): Hide download button in asset viewer "immersive mode" (#23720) * Hide download FAB in asset viewer immersive mode * Remove commented out code * Remove more comments --- .../widgets/asset_viewer/asset_viewer.page.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 f8a2c37ccd..50c4347301 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -627,10 +627,10 @@ class _AssetViewerState extends ConsumerState { // Rebuild the widget when the asset viewer state changes // Using multiple selectors to avoid unnecessary rebuilds for other state changes ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - ref.watch(assetViewerProvider.select((s) => s.showingControls)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(assetViewerProvider.select((s) => s.stackIndex)); ref.watch(isPlayingMotionVideoProvider); + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); // Listen for casting changes and send initial asset to the cast provider ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async { @@ -663,7 +663,14 @@ class _AssetViewerState extends ConsumerState { appBar: const ViewerTopAppBar(), extendBody: true, extendBodyBehindAppBar: true, - floatingActionButton: const DownloadStatusFloatingButton(), + floatingActionButton: IgnorePointer( + ignoring: !showingControls, + child: AnimatedOpacity( + opacity: showingControls ? 1.0 : 0.0, + duration: Durations.short2, + child: const DownloadStatusFloatingButton(), + ), + ), body: Stack( children: [ PhotoViewGallery.builder( From d27c01ef70ec0535d613fa5ec1ed714bc02d65cf Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:16:49 +0100 Subject: [PATCH 42/93] chore: migrate remaining usages of the logo to use the UI lib (#23430) --- .../components/album-page/album-viewer.svelte | 7 +- .../lib/components/layouts/ErrorLayout.svelte | 5 +- .../onboarding-page/onboarding-hello.svelte | 4 +- .../components/pages/SharedLinkPage.svelte | 7 +- .../individual-shared-viewer.svelte | 7 +- .../drag-and-drop-upload-overlay.svelte | 4 +- .../immich-logo-small-link.svelte | 8 -- .../shared-components/immich-logo.svelte | 112 ------------------ .../navigation-bar/navigation-bar.svelte | 5 +- .../side-bar/purchase-info.svelte | 10 +- .../side-bar/supporter-badge.svelte | 4 +- 11 files changed, 25 insertions(+), 148 deletions(-) delete mode 100644 web/src/lib/components/shared-components/immich-logo-small-link.svelte delete mode 100644 web/src/lib/components/shared-components/immich-logo.svelte diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 460cf4e63b..d0d03eef80 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -16,11 +16,10 @@ import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; - import { IconButton } from '@immich/ui'; + import { IconButton, Logo } from '@immich/ui'; import { mdiDownload, mdiFileImagePlusOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import ControlAppBar from '../shared-components/control-app-bar.svelte'; - import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte'; import ThemeButton from '../shared-components/theme-button.svelte'; import AlbumSummary from './album-summary.svelte'; @@ -98,7 +97,9 @@ {:else} {#snippet leading()} - + + + {/snippet} {#snippet trailing()} diff --git a/web/src/lib/components/layouts/ErrorLayout.svelte b/web/src/lib/components/layouts/ErrorLayout.svelte index b97b1f05d5..1df1dbf422 100644 --- a/web/src/lib/components/layouts/ErrorLayout.svelte +++ b/web/src/lib/components/layouts/ErrorLayout.svelte @@ -1,7 +1,6 @@
- +

{$t('onboarding_welcome_user', { values: { user: $user.name } })}

diff --git a/web/src/lib/components/pages/SharedLinkPage.svelte b/web/src/lib/components/pages/SharedLinkPage.svelte index b9ed34c0e8..d30c3ce341 100644 --- a/web/src/lib/components/pages/SharedLinkPage.svelte +++ b/web/src/lib/components/pages/SharedLinkPage.svelte @@ -2,7 +2,6 @@ import AlbumViewer from '$lib/components/album-page/album-viewer.svelte'; import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; - import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte'; import ThemeButton from '$lib/components/shared-components/theme-button.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { user } from '$lib/stores/user.store'; @@ -10,7 +9,7 @@ import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; import { getMySharedLink, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; - import { Button, PasswordInput } from '@immich/ui'; + import { Button, Logo, PasswordInput } from '@immich/ui'; import { tick } from 'svelte'; import { t } from 'svelte-i18n'; @@ -87,7 +86,9 @@
{#snippet leading()} - + + + {/snippet} {#snippet trailing()} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index f805e92de0..b7d9e548df 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -1,7 +1,6 @@ - - - - diff --git a/web/src/lib/components/shared-components/immich-logo.svelte b/web/src/lib/components/shared-components/immich-logo.svelte deleted file mode 100644 index a57f367964..0000000000 --- a/web/src/lib/components/shared-components/immich-logo.svelte +++ /dev/null @@ -1,112 +0,0 @@ - - - - {$t('immich_logo')} - {#if !noText} - - - - - - - - - {/if} - - - - - - - - - - diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index f73f9f3b51..03c5ca3be4 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -6,7 +6,6 @@ import { page } from '$app/state'; import { clickOutside } from '$lib/actions/click-outside'; import CastButton from '$lib/cast/cast-button.svelte'; - import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import { AppRoute } from '$lib/constants'; @@ -17,7 +16,7 @@ import { featureFlags } from '$lib/stores/server-config.store'; import { sidebarStore } from '$lib/stores/sidebar.svelte'; import { user } from '$lib/stores/user.store'; - import { Button, IconButton } from '@immich/ui'; + import { Button, IconButton, Logo } from '@immich/ui'; import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -78,7 +77,7 @@ class="sidebar:hidden" /> - +
diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index eb4433aa2b..8c16e3ba38 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -1,7 +1,5 @@ {#if albums.length > 0} diff --git a/web/src/lib/components/asset-viewer/actions/share-action.svelte b/web/src/lib/components/asset-viewer/actions/share-action.svelte index a284d5e364..bfd3312f3b 100644 --- a/web/src/lib/components/asset-viewer/actions/share-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/share-action.svelte @@ -1,6 +1,5 @@ diff --git a/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte b/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte index 06291e2188..202b87a15b 100644 --- a/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte +++ b/web/src/lib/components/timeline/actions/CreateSharedLinkAction.svelte @@ -1,7 +1,6 @@ diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 7c7717a4d4..44ee524658 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,5 +1,5 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; -import type { LoginResponseDto } from '@immich/sdk'; +import type { LoginResponseDto, SharedLinkResponseDto } from '@immich/sdk'; export type Events = { AppInit: []; @@ -8,6 +8,8 @@ export type Events = { AuthLogout: []; LanguageChange: [{ name: string; code: string; rtl?: boolean }]; ThemeChange: [ThemeSetting]; + SharedLinkCreate: [SharedLinkResponseDto]; + SharedLinkUpdate: [SharedLinkResponseDto]; }; type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; diff --git a/web/src/lib/modals/SharedLinkCreateModal.svelte b/web/src/lib/modals/SharedLinkCreateModal.svelte index b8444705fa..5ba0402752 100644 --- a/web/src/lib/modals/SharedLinkCreateModal.svelte +++ b/web/src/lib/modals/SharedLinkCreateModal.svelte @@ -1,26 +1,15 @@ + +
diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 2aa24a57ba..4b63ce71a1 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte'; import { AppRoute } from '$lib/constants'; import GroupTab from '$lib/elements/GroupTab.svelte'; @@ -50,18 +51,6 @@ } }; - const handleEditDone = async (updatedLink?: SharedLinkResponseDto) => { - if (updatedLink) { - const index = sharedLinks.findIndex((link) => link.id === updatedLink.id); - if (index !== -1) { - sharedLinks[index] = updatedLink; - } - } else { - await refresh(); - } - await goto(AppRoute.SHARED_LINKS); - }; - type Filter = 'all' | 'album' | 'individual'; const filterMap: Record = { @@ -91,8 +80,17 @@ (type === SharedLinkType.Individual && selectedTab === 'individual'), ), ); + + const onSharedLinkUpdate = (sharedLink: SharedLinkResponseDto) => { + const index = sharedLinks.findIndex((link) => link.id === sharedLink.id); + if (index !== -1) { + sharedLinks[index] = sharedLink; + } + }; + + {#snippet buttons()} From cb6d81771dbb8197322b2ef2568dc9b97fd1dd0c Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 11 Nov 2025 04:25:43 +0900 Subject: [PATCH 44/93] fix(mobile): sync album and asset activity state when add/remove asset level activity (#23484) * fix; sync album-asset state when remove activity * make build * fix: support adding case * make build * Update mobile/lib/providers/activity.provider.dart Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * fix: add missing import for collection package * make build --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --- mobile/lib/providers/activity.provider.dart | 53 ++++++++++-- mobile/lib/providers/activity.provider.g.dart | 2 +- .../activity/activity_provider_test.dart | 86 ++++++++++++++++--- 3 files changed, 118 insertions(+), 23 deletions(-) diff --git a/mobile/lib/providers/activity.provider.dart b/mobile/lib/providers/activity.provider.dart index a867a5a281..5e0e71d85d 100644 --- a/mobile/lib/providers/activity.provider.dart +++ b/mobile/lib/providers/activity.provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; @@ -16,13 +17,20 @@ class AlbumActivity extends _$AlbumActivity { Future removeActivity(String id) async { if (await ref.watch(activityServiceProvider).removeActivity(id)) { - final activities = state.valueOrNull ?? []; - final removedActivity = activities.firstWhere((a) => a.id == id); - activities.remove(removedActivity); - state = AsyncData(activities); - // Decrement activity count only for comments + final removedActivity = _removeFromState(id); + if (removedActivity == null) { + return; + } + + if (assetId != null) { + ref.read(albumActivityProvider(albumId).notifier)._removeFromState(id); + } + if (removedActivity.type == ActivityType.comment) { ref.watch(activityStatisticsProvider(albumId, assetId).notifier).removeActivity(); + if (assetId != null) { + ref.watch(activityStatisticsProvider(albumId).notifier).removeActivity(); + } } } } @@ -30,8 +38,10 @@ class AlbumActivity extends _$AlbumActivity { Future addLike() async { final activity = await ref.watch(activityServiceProvider).addActivity(albumId, ActivityType.like, assetId: assetId); if (activity.hasValue) { - final activities = state.asData?.value ?? []; - state = AsyncData([...activities, activity.requireValue]); + _addToState(activity.requireValue); + if (assetId != null) { + ref.read(albumActivityProvider(albumId).notifier)._addToState(activity.requireValue); + } } } @@ -41,8 +51,10 @@ class AlbumActivity extends _$AlbumActivity { .addActivity(albumId, ActivityType.comment, assetId: assetId, comment: comment); if (activity.hasValue) { - final activities = state.valueOrNull ?? []; - state = AsyncData([...activities, activity.requireValue]); + _addToState(activity.requireValue); + if (assetId != null) { + ref.read(albumActivityProvider(albumId).notifier)._addToState(activity.requireValue); + } ref.watch(activityStatisticsProvider(albumId, assetId).notifier).addActivity(); // The previous addActivity call would increase the count of an asset if assetId != null // To also increase the activity count of the album, calling it once again with assetId set to null @@ -51,6 +63,29 @@ class AlbumActivity extends _$AlbumActivity { } } } + + void _addToState(Activity activity) { + final activities = state.valueOrNull ?? []; + if (activities.any((a) => a.id == activity.id)) { + return; + } + state = AsyncData([...activities, activity]); + } + + Activity? _removeFromState(String id) { + final activities = state.valueOrNull; + if (activities == null) { + return null; + } + final activity = activities.firstWhereOrNull((a) => a.id == id); + if (activity == null) { + return null; + } + + final updated = [...activities]..remove(activity); + state = AsyncData(updated); + return activity; + } } /// Mock class for testing diff --git a/mobile/lib/providers/activity.provider.g.dart b/mobile/lib/providers/activity.provider.g.dart index dc927795f8..6ca99e4f72 100644 --- a/mobile/lib/providers/activity.provider.g.dart +++ b/mobile/lib/providers/activity.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$albumActivityHash() => r'3b0d7acee4d41c84b3f220784c3b904c83f836e6'; +String _$albumActivityHash() => r'154e8ae98da3efc142369eae46d4005468fd67da'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/test/modules/activity/activity_provider_test.dart b/mobile/test/modules/activity/activity_provider_test.dart index 7964b43cad..84eba62b70 100644 --- a/mobile/test/modules/activity/activity_provider_test.dart +++ b/mobile/test/modules/activity/activity_provider_test.dart @@ -33,6 +33,7 @@ final _activities = [ void main() { late ActivityServiceMock activityMock; late ActivityStatisticsMock activityStatisticsMock; + late ActivityStatisticsMock albumActivityStatisticsMock; late ProviderContainer container; late AlbumActivityProvider provider; late ListenerMock>> listener; @@ -44,17 +45,23 @@ void main() { setUp(() async { activityMock = ActivityServiceMock(); activityStatisticsMock = ActivityStatisticsMock(); + albumActivityStatisticsMock = ActivityStatisticsMock(); + container = TestUtils.createContainer( overrides: [ activityServiceProvider.overrideWith((ref) => activityMock), activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock), + activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock), ], ); // Mock values + when(() => activityStatisticsMock.build(any(), any())).thenReturn(0); + when(() => albumActivityStatisticsMock.build(any())).thenReturn(0); when( () => activityMock.getAllActivities('test-album', assetId: 'test-asset'), ).thenAnswer((_) async => [..._activities]); + when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]); // Init and wait for providers future to complete provider = albumActivityProvider('test-album', 'test-asset'); @@ -89,6 +96,10 @@ void main() { () => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'), ).thenAnswer((_) async => AsyncData(like)); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + await container.read(provider.notifier).addLike(); verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset')); @@ -99,6 +110,11 @@ void main() { // Never bump activity count for new likes verifyNever(() => activityStatisticsMock.addActivity()); + verifyNever(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(5)); + expect(albumActivities, contains(like)); }); test('Like failed', () async { @@ -107,6 +123,10 @@ void main() { () => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'), ).thenAnswer((_) async => AsyncError(Exception('Mock'), StackTrace.current)); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + await container.read(provider.notifier).addLike(); verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset')); @@ -114,6 +134,12 @@ void main() { final activities = await container.read(provider.future); expect(activities, hasLength(4)); expect(activities, isNot(contains(like))); + + verifyNever(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(4)); + expect(albumActivities, isNot(contains(like))); }); }); @@ -130,6 +156,7 @@ void main() { expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '3')))); verifyNever(() => activityStatisticsMock.removeActivity()); + verifyNever(() => albumActivityStatisticsMock.removeActivity()); }); test('Remove Like failed', () async { @@ -140,6 +167,9 @@ void main() { final activities = await container.read(provider.future); expect(activities, hasLength(4)); expect(activities, anyElement(predicate((Activity a) => a.id == '3'))); + + verifyNever(() => activityStatisticsMock.removeActivity()); + verifyNever(() => albumActivityStatisticsMock.removeActivity()); }); test('Comment successfully removed', () async { @@ -151,23 +181,35 @@ void main() { expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '1')))); verify(() => activityStatisticsMock.removeActivity()); + verify(() => albumActivityStatisticsMock.removeActivity()); + }); + + test('Removes activity from album state when asset scoped', () async { + when(() => activityMock.removeActivity('3')).thenAnswer((_) async => true); + when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]); + + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + + await container.read(provider.notifier).removeActivity('3'); + + final assetActivities = container.read(provider).requireValue; + final albumActivities = container.read(albumProvider).requireValue; + + expect(assetActivities, hasLength(3)); + expect(assetActivities, isNot(anyElement(predicate((Activity a) => a.id == '3')))); + + expect(albumActivities, hasLength(3)); + expect(albumActivities, isNot(anyElement(predicate((Activity a) => a.id == '3')))); + + verify(() => activityMock.removeActivity('3')); + verifyNever(() => activityStatisticsMock.removeActivity()); + verifyNever(() => albumActivityStatisticsMock.removeActivity()); }); }); group('addComment()', () { - late ActivityStatisticsMock albumActivityStatisticsMock; - - setUp(() { - albumActivityStatisticsMock = ActivityStatisticsMock(); - container = TestUtils.createContainer( - overrides: [ - activityServiceProvider.overrideWith((ref) => activityMock), - activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock), - activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock), - ], - ); - }); - test('Comment successfully added', () async { final comment = Activity( id: '5', @@ -178,6 +220,10 @@ void main() { assetId: 'test-asset', ); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + when( () => activityMock.addActivity( 'test-album', @@ -206,6 +252,10 @@ void main() { verify(() => activityStatisticsMock.addActivity()); verify(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(5)); + expect(albumActivities, contains(comment)); }); test('Comment successfully added without assetId', () async { @@ -225,6 +275,8 @@ void main() { when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]); final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); await container.read(albumProvider.notifier).addComment('Test-Comment'); verify( @@ -258,6 +310,10 @@ void main() { ), ).thenAnswer((_) async => AsyncError(Exception('Error'), StackTrace.current)); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + await container.read(provider.notifier).addComment('Test-Comment'); final activities = await container.read(provider.future); @@ -266,6 +322,10 @@ void main() { verifyNever(() => activityStatisticsMock.addActivity()); verifyNever(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(4)); + expect(albumActivities, isNot(contains(comment))); }); }); } From b0a0b7c2e1eefe8d921f64f406e3c4c74f8de646 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 11 Nov 2025 04:26:27 +0900 Subject: [PATCH 45/93] feat(mobile): chat-style for asset activity view (#23347) * feat(mobile): open assetviewer via album activities page * adjust ui behavior: keep current asset & disable initial forcus * init of v2... * refactoring... * refactor: remove _DismissibleWrapper * feat: initial scrolling to bottom * refactor: use feature toggle * refactor: new route page * fix: file name, dcm analyze * fix: test failure * fix: remove toggle and the exisitng style based on review feedback * refactorr: rename methods for clarity in comment bubble widget * feat: (mobile) chat-style asset activity timeline * chore: extract as a new file * chore: styling (based on 2c12bc56) * chore: clean up * fix: albumActivityProvider parameter * fix: review point * fix --- .../pages/drift_activities.page.dart | 156 +----------------- .../activities_bottom_sheet.widget.dart | 16 +- .../widgets/activities/comment_bubble.dart | 143 ++++++++++++++++ 3 files changed, 151 insertions(+), 164 deletions(-) create mode 100644 mobile/lib/widgets/activities/comment_bubble.dart diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index 30e7dd497a..b92d429aa1 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -3,21 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; 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/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/datetime_extensions.dart'; -import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/widgets/activities/comment_bubble.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/activity_service.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { @@ -27,10 +19,8 @@ class DriftActivitiesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.read(currentAssetNotifier) as RemoteAsset?; - - final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); + final activityNotifier = ref.read(albumActivityProvider(album.id).notifier); + final activities = ref.watch(albumActivityProvider(album.id)); final listViewScrollController = useScrollController(); void scrollToBottom() { @@ -46,7 +36,7 @@ class DriftActivitiesPage extends HookConsumerWidget { overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], child: Scaffold( appBar: AppBar( - title: asset == null ? Text(album.name) : null, + title: Text(album.name), actions: [const LikeActivityActionButton(menuItem: true)], actionsPadding: const EdgeInsets.only(right: 8), ), @@ -57,7 +47,7 @@ class DriftActivitiesPage extends HookConsumerWidget { activityWidgets.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: _CommentBubble(activity: activity), + child: CommentBubble(activity: activity), ), ); } @@ -91,139 +81,3 @@ class DriftActivitiesPage extends HookConsumerWidget { ); } } - -class _CommentBubble extends ConsumerWidget { - final Activity activity; - - const _CommentBubble({required this.activity}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final user = ref.watch(currentUserProvider); - final album = ref.watch(currentRemoteAlbumProvider)!; - final isOwn = activity.user.id == user?.id; - final canDelete = isOwn || album.ownerId == user?.id; - final hasAsset = activity.assetId != null && activity.assetId!.isNotEmpty; - final isLike = activity.type == ActivityType.like; - final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer; - - final activityNotifier = ref.read(albumActivityProvider(album.id, activity.assetId).notifier); - - Future openAssetViewer() async { - final activityService = ref.read(activityServiceProvider); - final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref); - if (route != null) await context.pushRoute(route); - } - - Widget avatar() { - if (isOwn) { - return const SizedBox.shrink(); - } - - return UserCircleAvatar(user: activity.user, size: 28, radius: 14); - } - - Widget? thumbnail() { - if (!hasAsset) { - return null; - } - - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150), - child: Stack( - children: [ - GestureDetector( - onTap: openAssetViewer, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(10)), - child: Image( - image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!), - fit: BoxFit.cover, - ), - ), - ), - if (isLike) - Positioned( - right: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), - child: Icon(Icons.favorite, color: Colors.red[600], size: 18), - ), - ), - ], - ), - ); - } - - // Likes Album widget (for likes without asset) - Widget? likesToAlbum() { - if (!isLike || hasAsset) { - return null; - } - - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), - child: Icon(Icons.favorite, color: Colors.red[600], size: 18), - ); - } - - Widget? commentBubble() { - if (activity.comment == null || activity.comment!.isEmpty) { - return null; - } - - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))), - child: Text( - activity.comment ?? '', - style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface), - ), - ), - ); - } - - // Combined content widgets - final List contentChildren = [thumbnail(), likesToAlbum(), commentBubble()].whereType().toList(); - - return DismissibleActivity( - onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null, - activity.id, - Align( - alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86), - child: Container( - margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOwn) ...[avatar(), const SizedBox(width: 8)], - // Content column - Column( - crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - ...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)), - Text( - '${activity.user.name} • ${activity.createdAt.timeAgo()}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - if (isOwn) const SizedBox(width: 8), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart index 81e64bed89..63669495b9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart @@ -3,14 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/widgets/activities/comment_bubble.dart'; import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/activities/activity_tile.dart'; -import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; class ActivitiesBottomSheet extends HookConsumerWidget { final DraggableScrollableController controller; @@ -28,7 +26,6 @@ class ActivitiesBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(currentRemoteAlbumProvider)!; final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; - final user = ref.watch(currentUserProvider); final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); @@ -47,16 +44,9 @@ class ActivitiesBottomSheet extends HookConsumerWidget { return const SizedBox.shrink(); } final activity = data[data.length - 1 - index]; - final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; return Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: DismissibleActivity( - activity.id, - ActivityTile(activity, isBottomSheet: true), - onDismiss: canDelete - ? (activityId) async => await activityNotifier.removeActivity(activity.id) - : null, - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: CommentBubble(activity: activity, isAssetActivity: true), ); }, childCount: data.length + 1), ); diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart new file mode 100644 index 0000000000..11d5c21cec --- /dev/null +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -0,0 +1,143 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/activity.provider.dart'; +import 'package:immich_mobile/providers/activity_service.provider.dart'; +import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +class CommentBubble extends ConsumerWidget { + final Activity activity; + final bool isAssetActivity; + + const CommentBubble({super.key, required this.activity, this.isAssetActivity = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(currentUserProvider); + final album = ref.watch(currentRemoteAlbumProvider)!; + final isOwn = activity.user.id == user?.id; + final canDelete = isOwn || album.ownerId == user?.id; + final showThumbnail = !isAssetActivity && activity.assetId != null && activity.assetId!.isNotEmpty; + final isLike = activity.type == ActivityType.like; + final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer; + + final activityNotifier = ref.read( + albumActivityProvider(album.id, isAssetActivity ? activity.assetId : null).notifier, + ); + + Future openAssetViewer() async { + final activityService = ref.read(activityServiceProvider); + final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref); + if (route != null) await context.pushRoute(route); + } + + // avatar (hidden for own messages) + Widget avatar = const SizedBox.shrink(); + if (!isOwn) { + avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14); + } + + // Thumbnail with tappable behavior and optional heart overlay + Widget? thumbnail; + if (showThumbnail) { + thumbnail = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150), + child: Stack( + children: [ + GestureDetector( + onTap: openAssetViewer, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: Image( + image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!), + fit: BoxFit.cover, + ), + ), + ), + if (isLike) + Positioned( + right: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), + child: Icon(Icons.favorite, color: Colors.red[600], size: 18), + ), + ), + ], + ), + ); + } + + // Likes widget + Widget? likes; + if (isLike && !showThumbnail) { + likes = Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), + child: Icon(Icons.favorite, color: Colors.red[600], size: 18), + ); + } + + // Comment bubble, comment-only + Widget? commentBubble; + if (activity.comment != null && activity.comment!.isNotEmpty) { + commentBubble = ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))), + child: Text( + activity.comment ?? '', + style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface), + ), + ), + ); + } + + // Combined content widgets + final List contentChildren = [thumbnail, likes, commentBubble].whereType().toList(); + + return DismissibleActivity( + onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null, + activity.id, + Align( + alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOwn) ...[avatar, const SizedBox(width: 8)], + // Content column + Column( + crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + ...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)), + Text( + '${activity.user.name} • ${activity.createdAt.timeAgo()}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + if (isOwn) const SizedBox(width: 8), + ], + ), + ), + ), + ), + ); + } +} From 787158247f6627ba15af7b79d752538901bafbdd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:50:19 +0000 Subject: [PATCH 46/93] fix(deps): update typescript-projects (#23588) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- mise.toml | 10 +- package.json | 2 +- pnpm-lock.yaml | 1103 +++++++++++++++++++++++----------------------- web/package.json | 4 +- 4 files changed, 568 insertions(+), 551 deletions(-) diff --git a/mise.toml b/mise.toml index 474f768137..cf3b86c6cc 100644 --- a/mise.toml +++ b/mise.toml @@ -2,7 +2,15 @@ experimental_monorepo_root = true [tools] node = "24.11.0" -pnpm = "10.19.0" +flutter = "3.35.7" +pnpm = "10.20.0" +terragrunt = "0.91.2" +opentofu = "1.10.6" + +[tools."github:CQLabs/homebrew-dcm"] +version = "1.30.0" +bin = "dcm" +postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm" [settings] experimental = true diff --git a/package.json b/package.json index 4c262de2b9..d08ea46edf 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8", + "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd", "engines": { "pnpm": ">=10.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 463c7ccb46..da1dd18d97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,10 +64,10 @@ importers: version: 4.13.4 '@types/node': specifier: ^22.18.13 - version: 22.18.13 + version: 22.19.0 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -109,16 +109,16 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(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: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(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: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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)) yaml: specifier: ^2.3.1 version: 2.8.1 @@ -212,13 +212,13 @@ importers: version: 3.7.1 '@types/node': specifier: ^22.18.13 - version: 22.18.13 + version: 22.19.0 '@types/oidc-provider': specifier: ^9.0.0 version: 9.5.0 '@types/pg': specifier: ^8.15.1 - version: 8.15.5 + version: 8.15.6 '@types/pngjs': specifier: ^6.0.4 version: 6.0.5 @@ -284,7 +284,7 @@ importers: version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) open-api/typescript-sdk: dependencies: @@ -294,7 +294,7 @@ importers: devDependencies: '@types/node': specifier: ^22.18.13 - version: 22.18.13 + version: 22.19.0 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -303,28 +303,28 @@ importers: dependencies: '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(bullmq@5.61.2) + version: 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) '@nestjs/platform-socket.io': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.7)(rxjs@7.8.2) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.8)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.0.0 - version: 6.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + version: 6.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) '@nestjs/swagger': specifier: ^11.0.2 - version: 11.2.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + version: 11.2.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -381,7 +381,7 @@ importers: version: 2.2.0 bullmq: specifier: ^5.51.0 - version: 5.61.2 + version: 5.62.1 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -450,16 +450,16 @@ importers: version: 2.0.2 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@types/inquirer@8.2.11)(@types/node@22.18.13)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 - version: 5.4.3(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(kysely@0.28.2)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(kysely@0.28.2)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^7.0.0 - version: 7.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + version: 7.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) nodemailer: specifier: ^7.0.0 version: 7.0.10 @@ -486,7 +486,7 @@ importers: version: 19.2.0(react@19.2.0) react-email: specifier: ^4.0.0 - version: 4.3.1 + version: 4.3.2 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -532,16 +532,16 @@ importers: version: 9.38.0 '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.10(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.13) + version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-express@11.1.7) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-express@11.1.8) '@swc/core': specifier: ^1.4.14 - version: 1.13.5(@swc/helpers@0.5.17) + version: 1.14.0(@swc/helpers@0.5.17) '@types/archiver': specifier: ^6.0.0 version: 6.0.4 @@ -583,7 +583,7 @@ importers: version: 2.0.0 '@types/node': specifier: ^22.18.13 - version: 22.18.13 + version: 22.19.0 '@types/nodemailer': specifier: ^7.0.0 version: 7.0.3 @@ -610,10 +610,10 @@ importers: version: 0.7.39 '@types/validator': specifier: ^13.15.2 - version: 13.15.3 + version: 13.15.4 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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)) eslint: specifier: ^9.14.0 version: 9.38.0(jiti@2.6.1) @@ -664,13 +664,13 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 - version: 1.5.8(@swc/core@1.13.5(@swc/helpers@0.5.17))(rollup@4.52.5) + version: 1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(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: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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) web: dependencies: @@ -685,7 +685,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.40.2 - version: 0.40.2(@internationalized/date@3.8.2)(svelte@5.41.3) + version: 0.40.2(@internationalized/date@3.8.2)(svelte@5.43.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -715,7 +715,7 @@ importers: version: 0.41.3 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.7(svelte@5.41.3) + version: 0.3.7(svelte@5.43.0) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -736,7 +736,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.0.8 + version: 20.0.10 intl-messageformat: specifier: ^10.7.11 version: 10.7.18 @@ -751,7 +751,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.9.0 + version: 5.10.0 pmtiles: specifier: ^4.3.0 version: 4.3.0 @@ -760,7 +760,7 @@ importers: version: 1.5.4 simple-icons: specifier: ^15.15.0 - version: 15.17.0 + version: 15.18.0 socket.io-client: specifier: ~4.8.0 version: 4.8.1 @@ -769,13 +769,13 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.41.3) + version: 4.0.1(svelte@5.43.0) svelte-maplibre: - specifier: ^1.2.0 - version: 1.2.3(svelte@5.41.3) + specifier: ^1.2.5 + version: 1.2.5(svelte@5.43.0) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.41.3) + version: 0.12.0(svelte@5.43.0) tabbable: specifier: ^6.2.0 version: 6.3.0 @@ -797,25 +797,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) + version: 3.0.10(@sveltejs/kit@2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 4.1.16(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(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.9.2)(happy-dom@20.0.8)(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.8(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.0)(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.0) @@ -839,7 +839,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(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)) dotenv: specifier: ^17.0.0 version: 17.2.3 @@ -854,7 +854,7 @@ importers: version: 6.0.2(eslint@9.38.0(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.41.3) + version: 3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.43.0) eslint-plugin-unicorn: specifier: ^61.0.2 version: 61.0.2(eslint@9.38.0(jiti@2.6.1)) @@ -875,19 +875,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.41.3) + version: 3.4.0(prettier@3.6.2)(svelte@5.43.0) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.52.5) svelte: - specifier: 5.41.3 - version: 5.41.3 + specifier: 5.43.0 + version: 5.43.0 svelte-check: specifier: ^4.1.5 - version: 4.3.3(picomatch@4.0.3)(svelte@5.41.3)(typescript@5.9.3) + version: 4.3.3(picomatch@4.0.3)(svelte@5.43.0)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.0(svelte@5.41.3) + version: 1.4.0(svelte@5.43.0) tailwindcss: specifier: ^4.1.7 version: 4.1.16 @@ -899,10 +899,10 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(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: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(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) packages: @@ -2238,8 +2238,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.11': - resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -2250,8 +2250,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.11': - resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -2262,8 +2262,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.11': - resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -2274,8 +2274,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.11': - resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -2286,8 +2286,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.11': - resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -2298,8 +2298,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.11': - resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -2310,8 +2310,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.11': - resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -2322,8 +2322,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.11': - resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -2334,8 +2334,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.11': - resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -2346,8 +2346,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.11': - resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -2358,8 +2358,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.11': - resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -2370,8 +2370,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.11': - resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -2382,8 +2382,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.11': - resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -2394,8 +2394,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.11': - resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -2406,8 +2406,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.11': - resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -2418,8 +2418,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.11': - resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -2430,14 +2430,14 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.11': - resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.11': - resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -2448,14 +2448,14 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.11': - resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.11': - resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -2466,14 +2466,14 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.11': - resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.11': - resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -2484,8 +2484,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.11': - resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -2496,8 +2496,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.11': - resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -2508,8 +2508,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.11': - resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -2520,8 +2520,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.11': - resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -3088,8 +3088,8 @@ packages: resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} engines: {node: '>=6.0.0'} - '@maplibre/maplibre-gl-style-spec@24.3.0': - resolution: {integrity: sha512-CTJc/Nvldv+GNQuis29VnyV0TYsFTgQBY3SNagTzZ28oHDsDYJ7LwEmfick4Z30wPwI/4gXe3se8PH2IIfLx2g==} + '@maplibre/maplibre-gl-style-spec@24.3.1': + resolution: {integrity: sha512-TUM5JD40H2mgtVXl5IwWz03BuQabw8oZQLJTmPpJA0YTYF+B+oZppy5lNMO6bMvHzB+/5mxqW9VLG3wFdeqtOw==} hasBin: true '@maplibre/vt-pbf@4.0.3': @@ -3178,8 +3178,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.7': - resolution: {integrity: sha512-lwlObwGgIlpXSXYOTpfzdCepUyWomz6bv9qzGzzvpgspUxkj0Uz0fUJcvD44V8Ps7QhKW3lZBoYbXrH25UZrbA==} + '@nestjs/common@11.1.8': + resolution: {integrity: sha512-bbsOqwld/GdBfiRNc4nnjyWWENDEicq4SH+R5AuYatvf++vf1x5JIsHB1i1KtfZMD3eRte0D4K9WXuAYil6XAg==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -3191,8 +3191,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.7': - resolution: {integrity: sha512-TyXFOwjhHv/goSgJ8i20K78jwTM0iSpk9GBcC2h3mf4MxNy+znI8m7nWjfoACjTkb89cTwDQetfTHtSfGLLaiA==} + '@nestjs/core@11.1.8': + resolution: {integrity: sha512-7riWfmTmMhCJHZ5ZiaG+crj4t85IPCq/wLRuOUSigBYyFT2JZj0lVHtAdf4Davp9ouNI8GINBDt9h9b5Gz9nTw==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -3222,14 +3222,14 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.7': - resolution: {integrity: sha512-5T+GLdvTiGPKB4/P4PM9ftKUKNHJy8ThEFhZA3vQnXVL7Vf0rDr07TfVTySVu+XTh85m1lpFVuyFM6u6wLNsRA==} + '@nestjs/platform-express@11.1.8': + resolution: {integrity: sha512-rL6pZH9BW7BnL5X2eWbJMtt86uloAKjFgyY5+L2UkizgfEp7rgAs0+Z1z0BcW2Pgu5+q8O7RKPNyHJ/9ZNz/ZQ==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-socket.io@11.1.7': - resolution: {integrity: sha512-suAyy5JWWvqU0fXbRp79Ihy7a1HSfB5rKgecVRmuQQyTi28W/0lsRsJN41plsxOEiXtaZq7sqiQp5Dg4XeUc9g==} + '@nestjs/platform-socket.io@11.1.8': + resolution: {integrity: sha512-nMUvwcdztso8BjN9czRl4sm0Ewc5xrCcgLvy+QPt6VAnTdu06KcZqtA6Cl3MKxViSQsQ8NBN5foKvZehNt/tug==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -3263,8 +3263,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.7': - resolution: {integrity: sha512-QbtrgSlc3QVo6RHNxTTlyhaiobLLy8kvhOlgWHsoXRknybuRs7vZg4k5mo3ye6pITGeT3CrWIRpZjUsh5Wps5Q==} + '@nestjs/testing@11.1.8': + resolution: {integrity: sha512-E6K+0UTKztcPxJzLnQa7S34lFjZbrj3Z1r7c5y5WDrL1m5HD1H4AeyBhicHgdaFmxjLAva2bq0sYKy/S7cdeYA==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3276,8 +3276,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.1.7': - resolution: {integrity: sha512-FWPgZPN7yQWIeonQ7JL64Rbsbw/IQovft0cVC5UX1Jbsovq+rUaTuk3rilimGrawN9VOGcoiQLGNiIbmjjiCew==} + '@nestjs/websockets@11.1.8': + resolution: {integrity: sha512-RXo2336p/vyAwJ0qPInglzNSQ//qz+JTLr2LE1vlbmN5WcyB7zV6+gY06YgNdsr3oy/cXRh7fnC3Ph/VAu1EVg==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -4091,8 +4091,8 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.47.3': - resolution: {integrity: sha512-zN2yzBc2dIES2BSzLhNP2weYhwB77kgM/oAktICZVmmljyEmPZrlUwr14jjdK9/eDu7WdAuf6gTdYIJLTcN3Fw==} + '@sveltejs/kit@2.48.3': + resolution: {integrity: sha512-jf8mx3yctRXE9hvixgcqqK94YI2hDnbxI/12Upkz99XFMvxnJKCMzvz0j7lmbXSyBSNEycWO5xHvi7b73y9qkQ==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4197,68 +4197,68 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.13.5': - resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} + '@swc/core-darwin-arm64@1.14.0': + resolution: {integrity: sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.13.5': - resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} + '@swc/core-darwin-x64@1.14.0': + resolution: {integrity: sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.13.5': - resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} + '@swc/core-linux-arm-gnueabihf@1.14.0': + resolution: {integrity: sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.13.5': - resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} + '@swc/core-linux-arm64-gnu@1.14.0': + resolution: {integrity: sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.13.5': - resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} + '@swc/core-linux-arm64-musl@1.14.0': + resolution: {integrity: sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.13.5': - resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} + '@swc/core-linux-x64-gnu@1.14.0': + resolution: {integrity: sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.13.5': - resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} + '@swc/core-linux-x64-musl@1.14.0': + resolution: {integrity: sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.13.5': - resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} + '@swc/core-win32-arm64-msvc@1.14.0': + resolution: {integrity: sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.13.5': - resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} + '@swc/core-win32-ia32-msvc@1.14.0': + resolution: {integrity: sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.13.5': - resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} + '@swc/core-win32-x64-msvc@1.14.0': + resolution: {integrity: sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.13.5': - resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} + '@swc/core@1.14.0': + resolution: {integrity: sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4650,11 +4650,11 @@ packages: '@types/node@20.19.24': resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} - '@types/node@22.18.13': - resolution: {integrity: sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==} + '@types/node@22.19.0': + resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} - '@types/node@24.9.2': - resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} + '@types/node@24.10.0': + resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} '@types/nodemailer@7.0.3': resolution: {integrity: sha512-fC8w49YQ868IuPWRXqPfLf+MuTRex5Z1qxMoG8rr70riqqbOp2F5xgOKE9fODEBPzpnvjkJXFgK6IL2xgMSTnA==} @@ -4671,6 +4671,9 @@ packages: '@types/pg@8.15.5': resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} @@ -4761,8 +4764,8 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/validator@13.15.3': - resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} + '@types/validator@13.15.4': + resolution: {integrity: sha512-LSFfpSnJJY9wbC0LQxgvfb+ynbHftFo0tMsFOl/J4wexLnYMmDSPaj2ZyDv3TkfL1UePxPrxOWJfbiRS8mQv7A==} '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -5378,8 +5381,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.61.2: - resolution: {integrity: sha512-b39hbiq/xXpOTT/OfmKpQYD+4VE4+XUlvdZ6GjbGl9asmRk8cSvUaQWD18jVCn1I0SzIfbrgOf+RAkqjXDUhJg==} + bullmq@5.62.1: + resolution: {integrity: sha512-FiRxqSquGTf8W8l8OMczKfEFG0BEqJ5NzChwKZ4vbSpZSPFLSmmxXAQlW4imB1rZHnlc7sYq8o+Oy4JavoIEpQ==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -6410,8 +6413,8 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.25.11: - resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true @@ -7051,8 +7054,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.0.8: - resolution: {integrity: sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==} + happy-dom@20.0.10: + resolution: {integrity: sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -8018,8 +8021,8 @@ packages: resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} engines: {node: '>=6.4.0'} - maplibre-gl@5.9.0: - resolution: {integrity: sha512-YxW9glb/YrDXGDhqy1u+aG113+L86ttAUpTd6sCkGHyUKMXOX8qbGHJQVqxOczy+4CtRKnqcCfSura2MzB0nQA==} + maplibre-gl@5.10.0: + resolution: {integrity: sha512-eLhlX8Fnpaoo7+uGqggZmXmZld6WrbzOJUPB7G8JB8XpminlTnrQTtXilMHduR8fsNVxrzD8yRRqEoajONc8LQ==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -9673,8 +9676,8 @@ packages: peerDependencies: react: ^19.2.0 - react-email@4.3.1: - resolution: {integrity: sha512-GBgI7fl0fXVFVQ4zlXG+x14egDNX1WVlOrAXKMyc1h9xeTnIAt/u3g1liU4v+7Yv3yprMSkZ1mIO3YPuTKo77A==} + react-email@4.3.2: + resolution: {integrity: sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA==} engines: {node: '>=18.0.0'} hasBin: true @@ -10168,8 +10171,8 @@ packages: simple-get@3.1.1: resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} - simple-icons@15.17.0: - resolution: {integrity: sha512-viOcugYj+JFYVWJvDh4Ph1xHk9iTGhzt+NoPrfAQYSCADvmZFSQUWyKEbSMuqVRUsaRgvADn+Cczysemsf1N3Q==} + simple-icons@15.18.0: + resolution: {integrity: sha512-lYpvaIuZZr6N50YSdYZQzrKccSSF3dqcgcoz2vMKVQCc/fJWD8nFszJVZz2tCDTSu082rqRYfuYRUPhjdixDAA==} engines: {node: '>=0.12.18'} sirv@2.0.4: @@ -10480,8 +10483,8 @@ packages: peerDependencies: svelte: ^3 || ^4 || ^5 - svelte-maplibre@1.2.3: - resolution: {integrity: sha512-2EToGWdSlTq9Tr7MLmUlve3J86uDM9D6s5ErY/oc5LEsktd0TCTPXM1HJ1IGSaa+ElxCv/ka/igvGPb6L4BhLw==} + svelte-maplibre@1.2.5: + resolution: {integrity: sha512-Uklcbi6inW9GA0MuSusbXmFr/MQPmXrjuP8hS1+yFX3ySvCQ477tsM3I7Jo/fUDK3XAxFSIHW6hZfucnM3kXwQ==} peerDependencies: '@deck.gl/core': ^9 '@deck.gl/layers': ^9 @@ -10512,8 +10515,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.41.3: - resolution: {integrity: sha512-bkHg+whEnVVNcK3XP8Dy4NHujn5mU/+at9z09PXM5THKm+E73AwiKFoRMMTfyAzAj1yExKtudvGHq8UqOh8kMQ==} + svelte@5.43.0: + resolution: {integrity: sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11714,11 +11717,11 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/schematics-cli@19.2.15(@types/node@22.18.13)(chokidar@4.0.3)': + '@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.0)(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.15(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@22.18.13) + '@inquirer/prompts': 7.3.2(@types/node@22.19.0) ansi-colors: 4.1.3 symbol-observable: 4.0.0 yargs-parser: 21.1.1 @@ -13968,148 +13971,148 @@ snapshots: '@esbuild/aix-ppc64@0.19.12': optional: true - '@esbuild/aix-ppc64@0.25.11': + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/android-arm64@0.19.12': optional: true - '@esbuild/android-arm64@0.25.11': + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm@0.19.12': optional: true - '@esbuild/android-arm@0.25.11': + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-x64@0.19.12': optional: true - '@esbuild/android-x64@0.25.11': + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/darwin-arm64@0.19.12': optional: true - '@esbuild/darwin-arm64@0.25.11': + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-x64@0.19.12': optional: true - '@esbuild/darwin-x64@0.25.11': + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.19.12': optional: true - '@esbuild/freebsd-arm64@0.25.11': + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-x64@0.19.12': optional: true - '@esbuild/freebsd-x64@0.25.11': + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/linux-arm64@0.19.12': optional: true - '@esbuild/linux-arm64@0.25.11': + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm@0.19.12': optional: true - '@esbuild/linux-arm@0.25.11': + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-ia32@0.19.12': optional: true - '@esbuild/linux-ia32@0.25.11': + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-loong64@0.19.12': optional: true - '@esbuild/linux-loong64@0.25.11': + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-mips64el@0.19.12': optional: true - '@esbuild/linux-mips64el@0.25.11': + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-ppc64@0.19.12': optional: true - '@esbuild/linux-ppc64@0.25.11': + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-riscv64@0.19.12': optional: true - '@esbuild/linux-riscv64@0.25.11': + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-s390x@0.19.12': optional: true - '@esbuild/linux-s390x@0.25.11': + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-x64@0.19.12': optional: true - '@esbuild/linux-x64@0.25.11': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.11': + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-x64@0.19.12': optional: true - '@esbuild/netbsd-x64@0.25.11': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.25.11': + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-x64@0.19.12': optional: true - '@esbuild/openbsd-x64@0.25.11': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.25.11': + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/sunos-x64@0.19.12': optional: true - '@esbuild/sunos-x64@0.25.11': + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/win32-arm64@0.19.12': optional: true - '@esbuild/win32-arm64@0.25.11': + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-ia32@0.19.12': optional: true - '@esbuild/win32-ia32@0.25.11': + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-x64@0.19.12': optional: true - '@esbuild/win32-x64@0.25.11': + '@esbuild/win32-x64@0.25.12': optional: true '@eslint-community/eslint-utils@4.9.0(eslint@9.38.0(jiti@2.6.1))': @@ -14211,10 +14214,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.21 '@grpc/grpc-js@1.14.0': @@ -14343,18 +14346,18 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.0.1(svelte@5.41.3)': + '@immich/svelte-markdown-preprocess@0.0.1(svelte@5.43.0)': dependencies: - svelte: 5.41.3 + svelte: 5.43.0 - '@immich/ui@0.40.2(@internationalized/date@3.8.2)(svelte@5.41.3)': + '@immich/ui@0.40.2(@internationalized/date@3.8.2)(svelte@5.43.0)': dependencies: - '@immich/svelte-markdown-preprocess': 0.0.1(svelte@5.41.3) + '@immich/svelte-markdown-preprocess': 0.0.1(svelte@5.43.0) '@mdi/js': 7.4.47 - bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.41.3) + bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.43.0) luxon: 3.7.2 - simple-icons: 15.17.0 - svelte: 5.41.3 + simple-icons: 15.18.0 + svelte: 5.43.0 svelte-highlight: 7.8.4 tailwind-merge: 3.3.1 tailwind-variants: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.16) @@ -14362,27 +14365,27 @@ snapshots: transitivePeerDependencies: - '@internationalized/date' - '@inquirer/checkbox@4.2.1(@types/node@22.18.13)': + '@inquirer/checkbox@4.2.1(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/confirm@5.1.15(@types/node@22.18.13)': + '@inquirer/confirm@5.1.15(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/core@10.1.15(@types/node@22.18.13)': + '@inquirer/core@10.1.15(@types/node@22.19.0)': dependencies: '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -14390,115 +14393,115 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/editor@4.2.17(@types/node@22.18.13)': + '@inquirer/editor@4.2.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/external-editor': 1.0.2(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/external-editor': 1.0.2(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/expand@4.0.17(@types/node@22.18.13)': + '@inquirer/expand@4.0.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/external-editor@1.0.2(@types/node@22.18.13)': + '@inquirer/external-editor@1.0.2(@types/node@22.19.0)': dependencies: chardet: 2.1.0 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@inquirer/figures@1.0.13': {} - '@inquirer/input@4.2.1(@types/node@22.18.13)': + '@inquirer/input@4.2.1(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/number@3.0.17(@types/node@22.18.13)': + '@inquirer/number@3.0.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/password@4.0.17(@types/node@22.18.13)': + '@inquirer/password@4.0.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/prompts@7.3.2(@types/node@22.18.13)': + '@inquirer/prompts@7.3.2(@types/node@22.19.0)': dependencies: - '@inquirer/checkbox': 4.2.1(@types/node@22.18.13) - '@inquirer/confirm': 5.1.15(@types/node@22.18.13) - '@inquirer/editor': 4.2.17(@types/node@22.18.13) - '@inquirer/expand': 4.0.17(@types/node@22.18.13) - '@inquirer/input': 4.2.1(@types/node@22.18.13) - '@inquirer/number': 3.0.17(@types/node@22.18.13) - '@inquirer/password': 4.0.17(@types/node@22.18.13) - '@inquirer/rawlist': 4.1.5(@types/node@22.18.13) - '@inquirer/search': 3.1.0(@types/node@22.18.13) - '@inquirer/select': 4.3.1(@types/node@22.18.13) + '@inquirer/checkbox': 4.2.1(@types/node@22.19.0) + '@inquirer/confirm': 5.1.15(@types/node@22.19.0) + '@inquirer/editor': 4.2.17(@types/node@22.19.0) + '@inquirer/expand': 4.0.17(@types/node@22.19.0) + '@inquirer/input': 4.2.1(@types/node@22.19.0) + '@inquirer/number': 3.0.17(@types/node@22.19.0) + '@inquirer/password': 4.0.17(@types/node@22.19.0) + '@inquirer/rawlist': 4.1.5(@types/node@22.19.0) + '@inquirer/search': 3.1.0(@types/node@22.19.0) + '@inquirer/select': 4.3.1(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/prompts@7.8.0(@types/node@22.18.13)': + '@inquirer/prompts@7.8.0(@types/node@22.19.0)': dependencies: - '@inquirer/checkbox': 4.2.1(@types/node@22.18.13) - '@inquirer/confirm': 5.1.15(@types/node@22.18.13) - '@inquirer/editor': 4.2.17(@types/node@22.18.13) - '@inquirer/expand': 4.0.17(@types/node@22.18.13) - '@inquirer/input': 4.2.1(@types/node@22.18.13) - '@inquirer/number': 3.0.17(@types/node@22.18.13) - '@inquirer/password': 4.0.17(@types/node@22.18.13) - '@inquirer/rawlist': 4.1.5(@types/node@22.18.13) - '@inquirer/search': 3.1.0(@types/node@22.18.13) - '@inquirer/select': 4.3.1(@types/node@22.18.13) + '@inquirer/checkbox': 4.2.1(@types/node@22.19.0) + '@inquirer/confirm': 5.1.15(@types/node@22.19.0) + '@inquirer/editor': 4.2.17(@types/node@22.19.0) + '@inquirer/expand': 4.0.17(@types/node@22.19.0) + '@inquirer/input': 4.2.1(@types/node@22.19.0) + '@inquirer/number': 3.0.17(@types/node@22.19.0) + '@inquirer/password': 4.0.17(@types/node@22.19.0) + '@inquirer/rawlist': 4.1.5(@types/node@22.19.0) + '@inquirer/search': 3.1.0(@types/node@22.19.0) + '@inquirer/select': 4.3.1(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/rawlist@4.1.5(@types/node@22.18.13)': + '@inquirer/rawlist@4.1.5(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/search@3.1.0(@types/node@22.18.13)': + '@inquirer/search@3.1.0(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/select@4.3.1(@types/node@22.18.13)': + '@inquirer/select@4.3.1(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/type@3.0.8(@types/node@22.18.13)': + '@inquirer/type@3.0.8(@types/node@22.19.0)': optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@internationalized/date@3.8.2': dependencies: @@ -14536,7 +14539,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/yargs': 17.0.34 chalk: 4.1.2 @@ -14702,7 +14705,7 @@ snapshots: '@mapbox/whoots-js@3.1.0': {} - '@maplibre/maplibre-gl-style-spec@24.3.0': + '@maplibre/maplibre-gl-style-spec@24.3.1': dependencies: '@mapbox/jsonlint-lines-primitives': 2.0.2 '@mapbox/unitbezier': 0.0.1 @@ -14790,32 +14793,32 @@ snapshots: '@namnode/store@0.1.0': {} - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(bullmq@5.61.2)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.61.2 + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.62.1 tslib: 2.8.1 - '@nestjs/cli@11.0.10(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.13)': + '@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.15(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.15(@types/node@22.18.13)(chokidar@4.0.3) - '@inquirer/prompts': 7.8.0(@types/node@22.18.13) + '@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.8.0(@types/node@22.19.0) '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.8.3) ansis: 4.1.0 chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))) glob: 11.0.3 node-emoji: 1.11.0 ora: 5.4.1 @@ -14823,17 +14826,17 @@ snapshots: tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.8.3 - webpack: 5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)) + webpack: 5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.13.5(@swc/helpers@0.5.17) + '@swc/core': 1.14.0(@swc/helpers@0.5.17) transitivePeerDependencies: - '@types/node' - esbuild - uglify-js - webpack-cli - '@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.0.0 iterare: 1.2.1 @@ -14848,9 +14851,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -14860,21 +14863,21 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) - '@nestjs/websockets': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) + '@nestjs/websockets': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.2 - '@nestjs/platform-express@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@nestjs/platform-express@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.1.0 multer: 2.0.2 @@ -14883,10 +14886,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.7)(rxjs@7.8.2)': + '@nestjs/platform-socket.io@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.8)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.1 tslib: 2.8.1 @@ -14895,10 +14898,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@nestjs/schedule@6.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.3.3 '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.8.3)': @@ -14923,12 +14926,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.15.1 - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) js-yaml: 4.1.0 lodash: 4.17.21 path-to-regexp: 8.3.0 @@ -14938,25 +14941,25 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.2 - '@nestjs/testing@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-express@11.1.7)': + '@nestjs/testing@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-express@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + '@nestjs/platform-express': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) - '@nestjs/websockets@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.7)(rxjs@7.8.2) + '@nestjs/platform-socket.io': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.8)(rxjs@7.8.2) '@noble/hashes@1.8.0': {} @@ -15859,29 +15862,29 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(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.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: - '@sveltejs/kit': 2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/kit': 2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) - '@sveltejs/enhanced-img@0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/enhanced-img@0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.41.3)(vite@7.1.12(@types/node@24.9.2)(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.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) magic-string: 0.30.21 sharp: 0.34.4 - svelte: 5.41.3 - svelte-parse-markup: 0.1.5(svelte@5.41.3) - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + svelte: 5.43.0 + svelte-parse-markup: 0.1.5(svelte@5.43.0) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite-imagetools: 8.0.0(rollup@4.52.5) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/kit@2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.6(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(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.43.0)(vite@7.1.12(@types/node@24.10.0)(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 @@ -15893,29 +15896,29 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.2 sirv: 3.0.2 - svelte: 5.41.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + svelte: 5.43.0 + vite: 7.1.12(@types/node@24.10.0)(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.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(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.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.41.3)(vite@7.1.12(@types/node@24.9.2)(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.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) debug: 4.4.3 - svelte: 5.41.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + svelte: 5.43.0 + vite: 7.1.12(@types/node@24.10.0)(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.41.3)(vite@7.1.12(@types/node@24.9.2)(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.43.0)(vite@7.1.12(@types/node@24.10.0)(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.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(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.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.41.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitefu: 1.1.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + svelte: 5.43.0 + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitefu: 1.1.1(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - supports-color @@ -16012,51 +16015,51 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.13.5': + '@swc/core-darwin-arm64@1.14.0': optional: true - '@swc/core-darwin-x64@1.13.5': + '@swc/core-darwin-x64@1.14.0': optional: true - '@swc/core-linux-arm-gnueabihf@1.13.5': + '@swc/core-linux-arm-gnueabihf@1.14.0': optional: true - '@swc/core-linux-arm64-gnu@1.13.5': + '@swc/core-linux-arm64-gnu@1.14.0': optional: true - '@swc/core-linux-arm64-musl@1.13.5': + '@swc/core-linux-arm64-musl@1.14.0': optional: true - '@swc/core-linux-x64-gnu@1.13.5': + '@swc/core-linux-x64-gnu@1.14.0': optional: true - '@swc/core-linux-x64-musl@1.13.5': + '@swc/core-linux-x64-musl@1.14.0': optional: true - '@swc/core-win32-arm64-msvc@1.13.5': + '@swc/core-win32-arm64-msvc@1.14.0': optional: true - '@swc/core-win32-ia32-msvc@1.13.5': + '@swc/core-win32-ia32-msvc@1.14.0': optional: true - '@swc/core-win32-x64-msvc@1.13.5': + '@swc/core-win32-x64-msvc@1.14.0': optional: true - '@swc/core@1.13.5(@swc/helpers@0.5.17)': + '@swc/core@1.14.0(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.13.5 - '@swc/core-darwin-x64': 1.13.5 - '@swc/core-linux-arm-gnueabihf': 1.13.5 - '@swc/core-linux-arm64-gnu': 1.13.5 - '@swc/core-linux-arm64-musl': 1.13.5 - '@swc/core-linux-x64-gnu': 1.13.5 - '@swc/core-linux-x64-musl': 1.13.5 - '@swc/core-win32-arm64-msvc': 1.13.5 - '@swc/core-win32-ia32-msvc': 1.13.5 - '@swc/core-win32-x64-msvc': 1.13.5 + '@swc/core-darwin-arm64': 1.14.0 + '@swc/core-darwin-x64': 1.14.0 + '@swc/core-linux-arm-gnueabihf': 1.14.0 + '@swc/core-linux-arm64-gnu': 1.14.0 + '@swc/core-linux-arm64-musl': 1.14.0 + '@swc/core-linux-x64-gnu': 1.14.0 + '@swc/core-linux-x64-musl': 1.14.0 + '@swc/core-win32-arm64-msvc': 1.14.0 + '@swc/core-win32-ia32-msvc': 1.14.0 + '@swc/core-win32-x64-msvc': 1.14.0 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -16134,12 +16137,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 - '@tailwindcss/vite@4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@tailwindcss/vite@4.1.16(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.1.16 '@tailwindcss/oxide': 4.1.16 tailwindcss: 4.1.16 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) '@testing-library/dom@10.4.0': dependencies: @@ -16161,13 +16164,13 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte@5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(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.9.2)(happy-dom@20.0.8)(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.8(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(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.0)(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.0 - svelte: 5.41.3 + svelte: 5.43.0 optionalDependencies: - vite: 7.1.12(@types/node@24.9.2)(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.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(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.0)(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@14.6.1(@testing-library/dom@10.4.0)': dependencies: @@ -16209,7 +16212,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/archiver@6.0.4': dependencies: @@ -16221,16 +16224,16 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/bonjour@3.5.13': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/braces@3.0.5': {} @@ -16251,21 +16254,21 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/compression@1.8.1': dependencies: '@types/express': 5.0.5 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/connect@3.4.38': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/content-disposition@0.5.9': {} @@ -16282,11 +16285,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.5 '@types/keygrip': 1.0.6 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/cors@2.8.19': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/debug@4.1.12': dependencies: @@ -16296,13 +16299,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2': 1.15.5 '@types/dockerode@3.3.45': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2': 1.15.5 '@types/dom-to-image@2.6.7': {} @@ -16325,14 +16328,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -16358,7 +16361,7 @@ snapshots: '@types/fluent-ffmpeg@2.1.28': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/geojson-vt@3.2.5': dependencies: @@ -16390,7 +16393,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/inquirer@8.2.11': dependencies: @@ -16428,7 +16431,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.8 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/leaflet@1.9.21': dependencies: @@ -16458,7 +16461,7 @@ snapshots: '@types/mock-fs@4.13.4': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ms@2.1.0': {} @@ -16468,7 +16471,7 @@ snapshots: '@types/node-forge@1.3.14': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/node@17.0.45': {} @@ -16480,11 +16483,11 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.18.13': + '@types/node@22.19.0': dependencies: undici-types: 6.21.0 - '@types/node@24.9.2': + '@types/node@24.10.0': dependencies: undici-types: 7.16.0 optional: true @@ -16492,7 +16495,7 @@ snapshots: '@types/nodemailer@7.0.3': dependencies: '@aws-sdk/client-sesv2': 3.919.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 transitivePeerDependencies: - aws-crt @@ -16500,17 +16503,23 @@ snapshots: dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/parse5@5.0.3': {} '@types/pg-pool@2.0.6': dependencies: - '@types/pg': 8.15.5 + '@types/pg': 8.15.6 '@types/pg@8.15.5': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 22.19.0 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -16518,13 +16527,13 @@ snapshots: '@types/pngjs@6.0.5': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/prismjs@1.26.5': {} '@types/qrcode@1.5.6': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/qs@6.14.0': {} @@ -16553,7 +16562,7 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/retry@0.12.2': {} @@ -16563,18 +16572,18 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/semver@7.7.1': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/send@1.2.1': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/serve-index@1.9.4': dependencies: @@ -16583,20 +16592,20 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/send': 0.17.6 '@types/sockjs@0.3.36': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2@0.5.52': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -16607,7 +16616,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 22.18.13 + '@types/node': 22.19.0 form-data: 4.0.4 '@types/supercluster@7.1.3': @@ -16621,7 +16630,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ua-parser-js@0.7.39': {} @@ -16629,13 +16638,13 @@ snapshots: '@types/unist@3.0.3': {} - '@types/validator@13.15.3': {} + '@types/validator@13.15.4': {} '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/yargs-parser@21.0.3': {} @@ -16740,7 +16749,7 @@ snapshots: '@vercel/oidc@3.0.3': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16755,11 +16764,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(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: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16774,7 +16783,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(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.0)(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) transitivePeerDependencies: - supports-color @@ -16786,21 +16795,21 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -16912,10 +16921,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.7(svelte@5.41.3)': + '@zoom-image/svelte@0.3.7(svelte@5.43.0)': dependencies: '@zoom-image/core': 0.41.3 - svelte: 5.41.3 + svelte: 5.43.0 abab@2.0.6: optional: true @@ -17275,15 +17284,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.9.8(@internationalized/date@3.8.2)(svelte@5.41.3): + bits-ui@2.9.8(@internationalized/date@3.8.2)(svelte@5.43.0): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.8.2 esm-env: 1.2.2 - runed: 0.29.2(svelte@5.41.3) - svelte: 5.41.3 - svelte-toolbelt: 0.9.3(svelte@5.41.3) + runed: 0.29.2(svelte@5.43.0) + svelte: 5.43.0 + svelte-toolbelt: 0.9.3(svelte@5.43.0) tabbable: 6.3.0 bl@4.1.0: @@ -17394,7 +17403,7 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.61.2: + bullmq@5.62.1: dependencies: cron-parser: 4.9.0 ioredis: 5.8.2 @@ -17602,7 +17611,7 @@ snapshots: class-validator@0.14.2: dependencies: - '@types/validator': 13.15.3 + '@types/validator': 13.15.4 libphonenumber-js: 1.12.9 validator: 13.15.15 @@ -18375,7 +18384,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.19 - '@types/node': 22.18.13 + '@types/node': 22.19.0 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -18489,34 +18498,34 @@ snapshots: '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 - esbuild@0.25.11: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.11 - '@esbuild/android-arm': 0.25.11 - '@esbuild/android-arm64': 0.25.11 - '@esbuild/android-x64': 0.25.11 - '@esbuild/darwin-arm64': 0.25.11 - '@esbuild/darwin-x64': 0.25.11 - '@esbuild/freebsd-arm64': 0.25.11 - '@esbuild/freebsd-x64': 0.25.11 - '@esbuild/linux-arm': 0.25.11 - '@esbuild/linux-arm64': 0.25.11 - '@esbuild/linux-ia32': 0.25.11 - '@esbuild/linux-loong64': 0.25.11 - '@esbuild/linux-mips64el': 0.25.11 - '@esbuild/linux-ppc64': 0.25.11 - '@esbuild/linux-riscv64': 0.25.11 - '@esbuild/linux-s390x': 0.25.11 - '@esbuild/linux-x64': 0.25.11 - '@esbuild/netbsd-arm64': 0.25.11 - '@esbuild/netbsd-x64': 0.25.11 - '@esbuild/openbsd-arm64': 0.25.11 - '@esbuild/openbsd-x64': 0.25.11 - '@esbuild/openharmony-arm64': 0.25.11 - '@esbuild/sunos-x64': 0.25.11 - '@esbuild/win32-arm64': 0.25.11 - '@esbuild/win32-ia32': 0.25.11 - '@esbuild/win32-x64': 0.25.11 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 escalade@3.2.0: {} @@ -18565,7 +18574,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.38.0(jiti@2.6.1)) - eslint-plugin-svelte@3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.41.3): + eslint-plugin-svelte@3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.43.0): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -18577,9 +18586,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.41.3) + svelte-eslint-parser: 1.4.0(svelte@5.43.0) optionalDependencies: - svelte: 5.41.3 + svelte: 5.43.0 transitivePeerDependencies: - ts-node @@ -18764,7 +18773,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 require-like: 0.1.2 event-emitter@0.3.5: @@ -19060,7 +19069,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -19075,7 +19084,7 @@ snapshots: semver: 7.7.3 tapable: 2.3.0 typescript: 5.8.3 - webpack: 5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)) + webpack: 5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)) form-data-encoder@2.1.4: {} @@ -19324,7 +19333,7 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.0.8: + happy-dom@20.0.10: dependencies: '@types/node': 20.19.24 '@types/whatwg-mimetype': 3.0.2 @@ -19753,9 +19762,9 @@ snapshots: inline-style-parser@0.2.4: {} - inquirer@8.2.7(@types/node@22.18.13): + inquirer@8.2.7(@types/node@22.19.0): dependencies: - '@inquirer/external-editor': 1.0.2(@types/node@22.18.13) + '@inquirer/external-editor': 1.0.2(@types/node@22.19.0) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -19969,7 +19978,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.18.13 + '@types/node': 22.19.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -19977,13 +19986,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -20435,7 +20444,7 @@ snapshots: tinyqueue: 2.0.3 vt-pbf: 3.1.3 - maplibre-gl@5.9.0: + maplibre-gl@5.10.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -20444,7 +20453,7 @@ snapshots: '@mapbox/unitbezier': 0.0.1 '@mapbox/vector-tile': 2.0.4 '@mapbox/whoots-js': 3.1.0 - '@maplibre/maplibre-gl-style-spec': 24.3.0 + '@maplibre/maplibre-gl-style-spec': 24.3.1 '@maplibre/vt-pbf': 4.0.3 '@types/geojson': 7946.0.16 '@types/geojson-vt': 3.2.5 @@ -21203,39 +21212,39 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@types/inquirer@8.2.11)(@types/node@22.18.13)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) - '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/inquirer': 8.2.11 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) - inquirer: 8.2.7(@types/node@22.18.13) + inquirer: 8.2.7(@types/node@22.19.0) transitivePeerDependencies: - '@types/node' - typescript - nestjs-cls@5.4.3(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - nestjs-kysely@3.1.2(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(kysely@0.28.2)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(kysely@0.28.2)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) kysely: 0.28.2 reflect-metadata: 0.2.2 tslib: 2.8.1 - nestjs-otel@7.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7): + nestjs-otel@7.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8): dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.36.0(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -22228,10 +22237,10 @@ snapshots: dependencies: prettier: 3.6.2 - prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.41.3): + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.43.0): dependencies: prettier: 3.6.2 - svelte: 5.41.3 + svelte: 5.43.0 prettier@3.6.2: {} @@ -22310,7 +22319,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 long: 5.3.2 protocol-buffers-schema@3.6.0: {} @@ -22418,14 +22427,14 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 - react-email@4.3.1: + react-email@4.3.2: dependencies: '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 chokidar: 4.0.3 commander: 13.1.0 debounce: 2.2.0 - esbuild: 0.25.11 + esbuild: 0.25.12 glob: 11.0.3 jiti: 2.4.2 log-symbols: 7.0.1 @@ -22841,10 +22850,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.29.2(svelte@5.41.3): + runed@0.29.2(svelte@5.43.0): dependencies: esm-env: 1.2.2 - svelte: 5.41.3 + svelte: 5.43.0 rw@1.3.3: {} @@ -23128,7 +23137,7 @@ snapshots: simple-concat: 1.0.1 optional: true - simple-icons@15.17.0: {} + simple-icons@15.18.0: {} sirv@2.0.4: dependencies: @@ -23460,19 +23469,19 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.41.3)(typescript@5.9.3): + svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.43.0)(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.41.3 + svelte: 5.43.0 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.0(svelte@5.41.3): + svelte-eslint-parser@1.4.0(svelte@5.43.0): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -23481,7 +23490,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.0 optionalDependencies: - svelte: 5.41.3 + svelte: 5.43.0 svelte-gestures@5.2.2: {} @@ -23489,7 +23498,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.41.3): + svelte-i18n@4.0.1(svelte@5.43.0): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -23497,34 +23506,34 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.41.3 + svelte: 5.43.0 tiny-glob: 0.2.9 - svelte-maplibre@1.2.3(svelte@5.41.3): + svelte-maplibre@1.2.5(svelte@5.43.0): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.9.0 + maplibre-gl: 5.10.0 pmtiles: 3.2.1 - svelte: 5.41.3 + svelte: 5.43.0 - svelte-parse-markup@0.1.5(svelte@5.41.3): + svelte-parse-markup@0.1.5(svelte@5.43.0): dependencies: - svelte: 5.41.3 + svelte: 5.43.0 - svelte-persisted-store@0.12.0(svelte@5.41.3): + svelte-persisted-store@0.12.0(svelte@5.43.0): dependencies: - svelte: 5.41.3 + svelte: 5.43.0 - svelte-toolbelt@0.9.3(svelte@5.41.3): + svelte-toolbelt@0.9.3(svelte@5.43.0): dependencies: clsx: 2.1.1 - runed: 0.29.2(svelte@5.41.3) + runed: 0.29.2(svelte@5.43.0) style-to-object: 1.0.11 - svelte: 5.41.3 + svelte: 5.43.0 - svelte@5.41.3: + svelte@5.43.0: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -23681,16 +23690,16 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))): + terser-webpack-plugin@5.3.14(@swc/core@1.14.0(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.0 - webpack: 5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)) + webpack: 5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.13.5(@swc/helpers@0.5.17) + '@swc/core': 1.14.0(@swc/helpers@0.5.17) terser-webpack-plugin@5.3.14(webpack@5.102.1): dependencies: @@ -24070,10 +24079,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.8(@swc/core@1.13.5(@swc/helpers@0.5.17))(rollup@4.52.5): + unplugin-swc@1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.52.5) - '@swc/core': 1.13.5(@swc/helpers@0.5.17) + '@swc/core': 1.14.0(@swc/helpers@0.5.17) load-tsconfig: 0.2.5 unplugin: 2.3.10 transitivePeerDependencies: @@ -24205,13 +24214,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -24226,13 +24235,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -24247,62 +24256,62 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: - esbuild: 0.25.11 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.0 yaml: 2.8.1 - vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: - esbuild: 0.25.11 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.9.2 + '@types/node': 24.10.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.0 yaml: 2.8.1 - vitefu@1.1.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vitefu@1.1.1(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): optionalDependencies: - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24320,13 +24329,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.18.13 - happy-dom: 20.0.8 + '@types/node': 22.19.0 + happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -24342,11 +24351,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24364,13 +24373,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.18.13 - happy-dom: 20.0.8 + '@types/node': 22.19.0 + happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -24386,11 +24395,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(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.0)(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: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24408,13 +24417,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.9.2 - happy-dom: 20.0.8 + '@types/node': 24.10.0 + happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -24553,7 +24562,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)): + webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -24577,7 +24586,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))) + terser-webpack-plugin: 5.3.14(@swc/core@1.14.0(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))) watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: diff --git a/web/package.json b/web/package.json index 24d489a7d8..b9ec394b5d 100644 --- a/web/package.json +++ b/web/package.json @@ -57,7 +57,7 @@ "socket.io-client": "~4.8.0", "svelte-gestures": "^5.2.2", "svelte-i18n": "^4.0.1", - "svelte-maplibre": "^1.2.0", + "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", "thumbhash": "^0.1.1" @@ -96,7 +96,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.41.3", + "svelte": "5.43.0", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", From 7a2c8e0662648b2a5a6d98fed9bb6e4944138991 Mon Sep 17 00:00:00 2001 From: exelix <13405476+exelix11@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:55:09 +0100 Subject: [PATCH 47/93] feat(mobile): Quick date picker in the search page (#22653) * Quick date picker * Include current year in quick date picker * Quick date picker: localization, fix datetime overflows * Properly localized 'last_months' * Move quick_date_picker.dart to lib/presentation/widgets/search * Wrap the quick date picker state into its own class, improve the interaction patterns * Fix last9Months value * Improve method naming * Subtitle for "custom range" in quick date picker * Fix style warnings * Fix lint warning * fix: mobile unawaited_futures lint (#21661) * chore: add unawaited_futures lint as warning * remove unused dcm lints They will be added back later on a case by case basis * fix warning * auto gen file * review changes * conflict resolution --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> * Quick date picker * Wrap the quick date picker state into its own class, improve the interaction patterns * chore: delete file from rebase --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: bwees --- i18n/en.json | 5 + .../pages/search/drift_search.page.dart | 101 ++++++--- .../widgets/search/quick_date_picker.dart | 208 ++++++++++++++++++ .../filter_bottom_sheet_scaffold.dart | 23 +- 4 files changed, 290 insertions(+), 47 deletions(-) create mode 100644 mobile/lib/presentation/widgets/search/quick_date_picker.dart diff --git a/i18n/en.json b/i18n/en.json index 644b74e715..6117e30106 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1197,6 +1197,8 @@ "import_path": "Import path", "in_albums": "In {count, plural, one {# album} other {# albums}}", "in_archive": "In archive", + "in_year": "In {year}", + "in_year_selector": "In", "include_archived": "Include archived", "include_shared_albums": "Include shared albums", "include_shared_partner_assets": "Include shared partner assets", @@ -1233,6 +1235,7 @@ "language_setting_description": "Select your preferred language", "large_files": "Large Files", "last": "Last", + "last_months": "{count, plural, one {Last month} other {Last # months}}", "last_seen": "Last seen", "latest_version": "Latest Version", "latitude": "Latitude", @@ -1554,6 +1557,8 @@ "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", "pick_a_location": "Pick a location", + "pick_custom_range": "Custom range", + "pick_date_range": "Select a date range", "pin_code_changed_successfully": "Successfully changed PIN code", "pin_code_reset_successfully": "Successfully reset PIN code", "pin_code_setup_successfully": "Successfully setup a PIN code", diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 5ded685e21..58ca892f5f 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; @@ -54,6 +55,7 @@ class DriftSearchPage extends HookConsumerWidget { ); final previousFilter = useState(null); + final dateInputFilter = useState(null); final peopleCurrentFilterWidget = useState(null); final dateRangeCurrentFilterWidget = useState(null); @@ -245,19 +247,54 @@ class DriftSearchPage extends HookConsumerWidget { ); } + datePicked(DateFilterInputModel? selectedDate) { + dateInputFilter.value = selectedDate; + if (selectedDate == null) { + filter.value = filter.value.copyWith(date: SearchDateFilter()); + + dateRangeCurrentFilterWidget.value = null; + unawaited(search()); + return; + } + + final date = selectedDate.asDateTimeRange(); + + filter.value = filter.value.copyWith( + date: SearchDateFilter( + takenAfter: date.start, + takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)), + ), + ); + + dateRangeCurrentFilterWidget.value = Text( + selectedDate.asHumanReadable(context), + style: context.textTheme.labelLarge, + ); + + unawaited(search()); + } + showDatePicker() async { final firstDate = DateTime(1900); final lastDate = DateTime.now(); + var dateRange = DateTimeRange( + start: filter.value.date.takenAfter ?? lastDate, + end: filter.value.date.takenBefore ?? lastDate, + ); + + // datePicked() may increase the date, this will make the date picker fail an assertion + // Fixup the end date to be at most now. + if (dateRange.end.isAfter(lastDate)) { + dateRange = DateTimeRange(start: dateRange.start, end: lastDate); + } + final date = await showDateRangePicker( context: context, firstDate: firstDate, lastDate: lastDate, currentDate: DateTime.now(), - initialDateRange: DateTimeRange( - start: filter.value.date.takenAfter ?? lastDate, - end: filter.value.date.takenBefore ?? lastDate, - ), + initialDateRange: dateRange, helpText: 'search_filter_date_title'.t(context: context), cancelText: 'cancel'.t(context: context), confirmText: 'select'.t(context: context), @@ -271,40 +308,32 @@ class DriftSearchPage extends HookConsumerWidget { ); if (date == null) { - filter.value = filter.value.copyWith(date: SearchDateFilter()); - - dateRangeCurrentFilterWidget.value = null; - unawaited(search()); - return; - } - - filter.value = filter.value.copyWith( - date: SearchDateFilter( - takenAfter: date.start, - takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)), - ), - ); - - // If date range is less than 24 hours, set the end date to the end of the day - if (date.end.difference(date.start).inHours < 24) { - dateRangeCurrentFilterWidget.value = Text( - DateFormat.yMMMd().format(date.start.toLocal()), - style: context.textTheme.labelLarge, - ); + datePicked(null); } else { - dateRangeCurrentFilterWidget.value = Text( - 'search_filter_date_interval'.t( - context: context, - args: { - "start": DateFormat.yMMMd().format(date.start.toLocal()), - "end": DateFormat.yMMMd().format(date.end.toLocal()), + datePicked(CustomDateFilter.fromRange(date)); + } + } + + showQuickDatePicker() { + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: "pick_date_range".tr(), + expanded: true, + onClear: () => datePicked(null), + child: QuickDatePicker( + currentInput: dateInputFilter.value, + onRequestPicker: () { + context.pop(); + showDatePicker(); + }, + onSelect: (date) { + context.pop(); + datePicked(date); }, ), - style: context.textTheme.labelLarge, - ); - } - - unawaited(search()); + ), + ); } // MEDIA PICKER @@ -589,7 +618,7 @@ class DriftSearchPage extends HookConsumerWidget { ), SearchFilterChip( icon: Icons.date_range_outlined, - onTap: showDatePicker, + onTap: showQuickDatePicker, label: 'search_filter_date'.t(context: context), currentFilter: dateRangeCurrentFilterWidget.value, ), diff --git a/mobile/lib/presentation/widgets/search/quick_date_picker.dart b/mobile/lib/presentation/widgets/search/quick_date_picker.dart new file mode 100644 index 0000000000..09b1cee700 --- /dev/null +++ b/mobile/lib/presentation/widgets/search/quick_date_picker.dart @@ -0,0 +1,208 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +sealed class DateFilterInputModel { + DateTimeRange asDateTimeRange(); + + String asHumanReadable(BuildContext context) { + // General implementation for arbitrary date and time ranges + // If date range is less than 24 hours, set the end date to the end of the day + final date = asDateTimeRange(); + if (date.end.difference(date.start).inHours < 24) { + return DateFormat.yMMMd().format(date.start.toLocal()); + } else { + return 'search_filter_date_interval'.t( + context: context, + args: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ); + } + } +} + +class RecentMonthRangeFilter extends DateFilterInputModel { + final int monthDelta; + RecentMonthRangeFilter(this.monthDelta); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + // Note that DateTime's constructor properly handles month overflow. + final from = DateTime(now.year, now.month - monthDelta, 1); + return DateTimeRange(start: from, end: now); + } + + @override + String asHumanReadable(BuildContext context) { + return 'last_months'.t(context: context, args: {"count": monthDelta.toString()}); + } +} + +class YearFilter extends DateFilterInputModel { + final int year; + YearFilter(this.year); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + final from = DateTime(year, 1, 1); + + if (now.year == year) { + // To not go beyond today if the user picks the current year + return DateTimeRange(start: from, end: now); + } + + final to = DateTime(year, 12, 31, 23, 59, 59); + return DateTimeRange(start: from, end: to); + } + + @override + String asHumanReadable(BuildContext context) { + return 'in_year'.tr(namedArgs: {"year": year.toString()}); + } +} + +class CustomDateFilter extends DateFilterInputModel { + final DateTime start; + final DateTime end; + + CustomDateFilter(this.start, this.end); + + factory CustomDateFilter.fromRange(DateTimeRange range) { + return CustomDateFilter(range.start, range.end); + } + + @override + DateTimeRange asDateTimeRange() { + return DateTimeRange(start: start, end: end); + } +} + +enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom } + +class QuickDatePicker extends HookWidget { + QuickDatePicker({super.key, required this.currentInput, required this.onSelect, required this.onRequestPicker}) + : _selection = _selectionFromModel(currentInput), + _initialYear = _initialYearFromModel(currentInput); + + final Function() onRequestPicker; + final Function(DateFilterInputModel range) onSelect; + + final DateFilterInputModel? currentInput; + final _QuickPickerType? _selection; + final int _initialYear; + + // Generate a list of recent years from 2000 to the current year (including the current one) + final List _recentYears = List.generate(1 + DateTime.now().year - 2000, (index) { + return index + 2000; + }); + + static int _initialYearFromModel(DateFilterInputModel? model) { + return model?.asDateTimeRange().start.year ?? DateTime.now().year; + } + + static _QuickPickerType? _selectionFromModel(DateFilterInputModel? model) { + if (model is RecentMonthRangeFilter) { + return switch (model.monthDelta) { + 1 => _QuickPickerType.last1Month, + 3 => _QuickPickerType.last3Months, + 9 => _QuickPickerType.last9Months, + _ => _QuickPickerType.custom, + }; + } else if (model is YearFilter) { + return _QuickPickerType.year; + } else if (model is CustomDateFilter) { + return _QuickPickerType.custom; + } + return null; + } + + Text _monthLabel(BuildContext context, int monthsFromNow) => + const Text('last_months').t(context: context, args: {"count": monthsFromNow.toString()}); + + Widget _yearPicker(BuildContext context) { + final size = MediaQuery.of(context).size; + return Row( + children: [ + const Text("in_year_selector").tr(), + const SizedBox(width: 15), + Expanded( + child: DropdownMenu( + initialSelection: _initialYear, + menuStyle: MenuStyle(maximumSize: WidgetStateProperty.all(Size(size.width, size.height * 0.5))), + dropdownMenuEntries: _recentYears.map((e) => DropdownMenuEntry(value: e, label: e.toString())).toList(), + onSelected: (year) { + if (year == null) return; + onSelect(YearFilter(year)); + }, + ), + ), + ], + ); + } + + // We want the exact date picker to always be selectable. + // Even if it's already toggled it should always open the full date picker, RadioListTiles don't do that by default + // so we wrap it in a InkWell + Widget _exactPicker(BuildContext context) { + final hasPreviousInput = currentInput != null && currentInput is CustomDateFilter; + + return InkWell( + onTap: onRequestPicker, + child: IgnorePointer( + ignoring: true, + child: RadioListTile( + title: const Text('pick_custom_range').tr(), + subtitle: hasPreviousInput ? Text(currentInput!.asHumanReadable(context)) : null, + secondary: hasPreviousInput ? const Icon(Icons.edit) : null, + value: _QuickPickerType.custom, + toggleable: true, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Scrollbar( + // Depending on the screen size the last option might get cut off + // Add a clear visual cue that there are more options when scrolling + // When the screen size is large enough the scrollbar is hidden automatically + trackVisibility: true, + thumbVisibility: true, + child: SingleChildScrollView( + child: RadioGroup( + onChanged: (value) { + if (value == null) return; + final _ = switch (value) { + _QuickPickerType.custom => onRequestPicker(), + _QuickPickerType.last1Month => onSelect(RecentMonthRangeFilter(1)), + _QuickPickerType.last3Months => onSelect(RecentMonthRangeFilter(3)), + _QuickPickerType.last9Months => onSelect(RecentMonthRangeFilter(9)), + // When a year is selected the combobox triggers onSelect() on its own. + // Here we handle the radio button being selected which can only ever be the initial year + _QuickPickerType.year => onSelect(YearFilter(_initialYear)), + }; + }, + groupValue: _selection, + child: Column( + children: [ + RadioListTile(title: _monthLabel(context, 1), value: _QuickPickerType.last1Month, toggleable: true), + RadioListTile(title: _monthLabel(context, 3), value: _QuickPickerType.last3Months, toggleable: true), + RadioListTile(title: _monthLabel(context, 9), value: _QuickPickerType.last9Months, toggleable: true), + RadioListTile(title: _yearPicker(context), value: _QuickPickerType.year, toggleable: true), + _exactPicker(context), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart index e8226b5b3a..dee42ec5a0 100644 --- a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart +++ b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart @@ -6,7 +6,7 @@ class FilterBottomSheetScaffold extends StatelessWidget { const FilterBottomSheetScaffold({ super.key, required this.child, - required this.onSearch, + this.onSearch, required this.onClear, required this.title, this.expanded, @@ -15,7 +15,7 @@ class FilterBottomSheetScaffold extends StatelessWidget { final bool? expanded; final String title; final Widget child; - final Function() onSearch; + final Function()? onSearch; final Function() onClear; @override @@ -48,15 +48,16 @@ class FilterBottomSheetScaffold extends StatelessWidget { }, child: const Text('clear').tr(), ), - const SizedBox(width: 8), - ElevatedButton( - key: const Key('search_filter_apply'), - onPressed: () { - onSearch(); - context.pop(); - }, - child: const Text('search_filter_apply').tr(), - ), + if (onSearch != null) const SizedBox(width: 8), + if (onSearch != null) + ElevatedButton( + key: const Key('search_filter_apply'), + onPressed: () { + onSearch!(); + context.pop(); + }, + child: const Text('search_filter_apply').tr(), + ), ], ), ), From 6922a92b69e8cccb06a5300f4976213371eac13a Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 10 Nov 2025 14:19:27 -0600 Subject: [PATCH 48/93] feat: show update version info (#23698) * feat: show update version info * Apply suggestions from code review Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- i18n/en.json | 1 + .../side-bar/server-status.svelte | 48 +++++++++++++++++-- web/src/lib/utils.ts | 2 + web/src/routes/+layout.svelte | 4 +- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 6117e30106..f0b10d2ac1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1414,6 +1414,7 @@ "new_pin_code": "New PIN code", "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", "new_timeline": "New Timeline", + "new_update": "New update", "new_user_created": "New user created", "new_version_available": "NEW VERSION AVAILABLE", "newest_first": "Newest first", diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte index 5c70955f60..b678bc5507 100644 --- a/web/src/lib/components/shared-components/side-bar/server-status.svelte +++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte @@ -1,7 +1,9 @@
info && modalManager.show(ServerAboutModal, { versions, info })} - class="dark:text-immich-gray flex gap-1" + class="dark:text-immich-gray flex gap-1 place-items-center place-content-center" > {#if isMain} {info?.sourceRef} @@ -69,3 +86,26 @@ {/if}
+ +{#if releaseInfo} + +
+
+ + + {releaseInfo.availableVersion} + +
+ + {$t('new_update')}! + +
+
+{/if} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 65510352c1..6e0a216477 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -400,3 +400,5 @@ export const getReleaseType = ( return 'none'; }; + +export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index f5d4619943..bd0029735c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -17,9 +17,8 @@ websocketStore, type ReleaseEvent, } from '$lib/stores/websocket'; - import { copyToClipboard, getReleaseType } from '$lib/utils'; + import { copyToClipboard, getReleaseType, semverToName } from '$lib/utils'; import { isAssetViewerRoute } from '$lib/utils/navigation'; - import type { ServerVersionResponseDto } from '@immich/sdk'; import { modalManager, setTranslations } from '@immich/ui'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; @@ -78,7 +77,6 @@ } }); - const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; const { release } = websocketStore; const handleRelease = async (release?: ReleaseEvent) => { From 9e2208b8dd4c327e5fca7c8d77f89f6904f2ec0e Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:21:08 -0500 Subject: [PATCH 49/93] chore(mobile): add table schemas to swift (#23749) * add schemas * handle json, improve type safety * formatting * sync variants * formatting --- mobile/ios/Runner.xcodeproj/project.pbxproj | 66 ++++- .../xcshareddata/swiftpm/Package.resolved | 150 +++++++++++ mobile/ios/Runner/Schemas/Constants.swift | 177 +++++++++++++ mobile/ios/Runner/Schemas/Store.swift | 146 +++++++++++ mobile/ios/Runner/Schemas/Tables.swift | 237 ++++++++++++++++++ 5 files changed, 772 insertions(+), 4 deletions(-) create mode 100644 mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 mobile/ios/Runner/Schemas/Constants.swift create mode 100644 mobile/ios/Runner/Schemas/Store.swift create mode 100644 mobile/ios/Runner/Schemas/Tables.swift diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 15130702bc..d2d9e8f0b7 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -32,6 +32,9 @@ FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; + FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; + FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; + FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -133,15 +136,11 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B231F52D2E93A44A00BC45D1 /* Core */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Core; sourceTree = ""; }; B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Sync; sourceTree = ""; }; @@ -153,6 +152,11 @@ path = WidgetExtension; sourceTree = ""; }; + FEE084F22EC172080045228E /* Schemas */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Schemas; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -160,6 +164,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FEE084F82EC172460045228E /* SQLiteData in Frameworks */, + FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */, + FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */, D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -254,6 +261,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + FEE084F22EC172080045228E /* Schemas */, B231F52D2E93A44A00BC45D1 /* Core */, B25D37792E72CA15008B6CA7 /* Connectivity */, B21E34A62E5AF9760031FDB9 /* Background */, @@ -341,6 +349,7 @@ fileSystemSynchronizedGroups = ( B231F52D2E93A44A00BC45D1 /* Core */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, + FEE084F22EC172080045228E /* Schemas */, ); name = Runner; productName = Runner; @@ -419,6 +428,10 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */, + FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -530,10 +543,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -562,10 +579,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -1201,6 +1222,43 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/sqlite-data"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.0; + }; + }; + FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-http-structured-headers.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + FEE084F72EC172460045228E /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */; + productName = SQLiteData; + }; + FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; + productName = RawStructuredFieldValues; + }; + FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; + productName = StructuredFieldValues; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..af8b19fa89 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,150 @@ +{ + "originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" + } + }, + { + "identity" : "sqlite-data", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/sqlite-data", + "state" : { + "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", + "version" : "2.0.9" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", + "version" : "2.7.4" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" + } + }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756", + "version" : "0.25.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner/Schemas/Constants.swift b/mobile/ios/Runner/Schemas/Constants.swift new file mode 100644 index 0000000000..a4b0f701a1 --- /dev/null +++ b/mobile/ios/Runner/Schemas/Constants.swift @@ -0,0 +1,177 @@ +import SQLiteData + +struct Endpoint: Codable { + let url: URL + let status: Status + + enum Status: String, Codable { + case loading, valid, error, unknown + } +} + +enum StoreKey: Int, CaseIterable, QueryBindable { + // MARK: - Int + case _version = 0 + static let version = Typed(rawValue: ._version) + case _deviceIdHash = 3 + static let deviceIdHash = Typed(rawValue: ._deviceIdHash) + case _backupTriggerDelay = 8 + static let backupTriggerDelay = Typed(rawValue: ._backupTriggerDelay) + case _tilesPerRow = 103 + static let tilesPerRow = Typed(rawValue: ._tilesPerRow) + case _groupAssetsBy = 105 + static let groupAssetsBy = Typed(rawValue: ._groupAssetsBy) + case _uploadErrorNotificationGracePeriod = 106 + static let uploadErrorNotificationGracePeriod = Typed(rawValue: ._uploadErrorNotificationGracePeriod) + case _thumbnailCacheSize = 110 + static let thumbnailCacheSize = Typed(rawValue: ._thumbnailCacheSize) + case _imageCacheSize = 111 + static let imageCacheSize = Typed(rawValue: ._imageCacheSize) + case _albumThumbnailCacheSize = 112 + static let albumThumbnailCacheSize = Typed(rawValue: ._albumThumbnailCacheSize) + case _selectedAlbumSortOrder = 113 + static let selectedAlbumSortOrder = Typed(rawValue: ._selectedAlbumSortOrder) + case _logLevel = 115 + static let logLevel = Typed(rawValue: ._logLevel) + case _mapRelativeDate = 119 + static let mapRelativeDate = Typed(rawValue: ._mapRelativeDate) + case _mapThemeMode = 124 + static let mapThemeMode = Typed(rawValue: ._mapThemeMode) + + // MARK: - String + case _assetETag = 1 + static let assetETag = Typed(rawValue: ._assetETag) + case _currentUser = 2 + static let currentUser = Typed(rawValue: ._currentUser) + case _deviceId = 4 + static let deviceId = Typed(rawValue: ._deviceId) + case _accessToken = 11 + static let accessToken = Typed(rawValue: ._accessToken) + case _serverEndpoint = 12 + static let serverEndpoint = Typed(rawValue: ._serverEndpoint) + case _sslClientCertData = 15 + static let sslClientCertData = Typed(rawValue: ._sslClientCertData) + case _sslClientPasswd = 16 + static let sslClientPasswd = Typed(rawValue: ._sslClientPasswd) + case _themeMode = 102 + static let themeMode = Typed(rawValue: ._themeMode) + case _customHeaders = 127 + static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders) + case _primaryColor = 128 + static let primaryColor = Typed(rawValue: ._primaryColor) + case _preferredWifiName = 133 + static let preferredWifiName = Typed(rawValue: ._preferredWifiName) + + // MARK: - Endpoint + case _externalEndpointList = 135 + static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList) + + // MARK: - URL + case _localEndpoint = 134 + static let localEndpoint = Typed(rawValue: ._localEndpoint) + case _serverUrl = 10 + static let serverUrl = Typed(rawValue: ._serverUrl) + + // MARK: - Date + case _backupFailedSince = 5 + static let backupFailedSince = Typed(rawValue: ._backupFailedSince) + + // MARK: - Bool + case _backupRequireWifi = 6 + static let backupRequireWifi = Typed(rawValue: ._backupRequireWifi) + case _backupRequireCharging = 7 + static let backupRequireCharging = Typed(rawValue: ._backupRequireCharging) + case _autoBackup = 13 + static let autoBackup = Typed(rawValue: ._autoBackup) + case _backgroundBackup = 14 + static let backgroundBackup = Typed(rawValue: ._backgroundBackup) + case _loadPreview = 100 + static let loadPreview = Typed(rawValue: ._loadPreview) + case _loadOriginal = 101 + static let loadOriginal = Typed(rawValue: ._loadOriginal) + case _dynamicLayout = 104 + static let dynamicLayout = Typed(rawValue: ._dynamicLayout) + case _backgroundBackupTotalProgress = 107 + static let backgroundBackupTotalProgress = Typed(rawValue: ._backgroundBackupTotalProgress) + case _backgroundBackupSingleProgress = 108 + static let backgroundBackupSingleProgress = Typed(rawValue: ._backgroundBackupSingleProgress) + case _storageIndicator = 109 + static let storageIndicator = Typed(rawValue: ._storageIndicator) + case _advancedTroubleshooting = 114 + static let advancedTroubleshooting = Typed(rawValue: ._advancedTroubleshooting) + case _preferRemoteImage = 116 + static let preferRemoteImage = Typed(rawValue: ._preferRemoteImage) + case _loopVideo = 117 + static let loopVideo = Typed(rawValue: ._loopVideo) + case _mapShowFavoriteOnly = 118 + static let mapShowFavoriteOnly = Typed(rawValue: ._mapShowFavoriteOnly) + case _selfSignedCert = 120 + static let selfSignedCert = Typed(rawValue: ._selfSignedCert) + case _mapIncludeArchived = 121 + static let mapIncludeArchived = Typed(rawValue: ._mapIncludeArchived) + case _ignoreIcloudAssets = 122 + static let ignoreIcloudAssets = Typed(rawValue: ._ignoreIcloudAssets) + case _selectedAlbumSortReverse = 123 + static let selectedAlbumSortReverse = Typed(rawValue: ._selectedAlbumSortReverse) + case _mapwithPartners = 125 + static let mapwithPartners = Typed(rawValue: ._mapwithPartners) + case _enableHapticFeedback = 126 + static let enableHapticFeedback = Typed(rawValue: ._enableHapticFeedback) + case _dynamicTheme = 129 + static let dynamicTheme = Typed(rawValue: ._dynamicTheme) + case _colorfulInterface = 130 + static let colorfulInterface = Typed(rawValue: ._colorfulInterface) + case _syncAlbums = 131 + static let syncAlbums = Typed(rawValue: ._syncAlbums) + case _autoEndpointSwitching = 132 + static let autoEndpointSwitching = Typed(rawValue: ._autoEndpointSwitching) + case _loadOriginalVideo = 136 + static let loadOriginalVideo = Typed(rawValue: ._loadOriginalVideo) + case _manageLocalMediaAndroid = 137 + static let manageLocalMediaAndroid = Typed(rawValue: ._manageLocalMediaAndroid) + case _readonlyModeEnabled = 138 + static let readonlyModeEnabled = Typed(rawValue: ._readonlyModeEnabled) + case _autoPlayVideo = 139 + static let autoPlayVideo = Typed(rawValue: ._autoPlayVideo) + case _photoManagerCustomFilter = 1000 + static let photoManagerCustomFilter = Typed(rawValue: ._photoManagerCustomFilter) + case _betaPromptShown = 1001 + static let betaPromptShown = Typed(rawValue: ._betaPromptShown) + case _betaTimeline = 1002 + static let betaTimeline = Typed(rawValue: ._betaTimeline) + case _enableBackup = 1003 + static let enableBackup = Typed(rawValue: ._enableBackup) + case _useWifiForUploadVideos = 1004 + static let useWifiForUploadVideos = Typed(rawValue: ._useWifiForUploadVideos) + case _useWifiForUploadPhotos = 1005 + static let useWifiForUploadPhotos = Typed(rawValue: ._useWifiForUploadPhotos) + case _needBetaMigration = 1006 + static let needBetaMigration = Typed(rawValue: ._needBetaMigration) + case _shouldResetSync = 1007 + static let shouldResetSync = Typed(rawValue: ._shouldResetSync) + + struct Typed: RawRepresentable { + let rawValue: StoreKey + + @_transparent + init(rawValue value: StoreKey) { + self.rawValue = value + } + } +} + +enum BackupSelection: Int, QueryBindable { + case selected, none, excluded +} + +enum AvatarColor: Int, QueryBindable { + case primary, pink, red, yellow, blue, green, purple, orange, gray, amber +} + +enum AlbumUserRole: Int, QueryBindable { + case editor, viewer +} + +enum MemoryType: Int, QueryBindable { + case onThisDay +} diff --git a/mobile/ios/Runner/Schemas/Store.swift b/mobile/ios/Runner/Schemas/Store.swift new file mode 100644 index 0000000000..ee5280b6c0 --- /dev/null +++ b/mobile/ios/Runner/Schemas/Store.swift @@ -0,0 +1,146 @@ +import SQLiteData + +enum StoreError: Error { + case invalidJSON(String) + case invalidURL(String) + case encodingFailed +} + +protocol StoreConvertible { + associatedtype StorageType + static func fromValue(_ value: StorageType) throws(StoreError) -> Self + static func toValue(_ value: Self) throws(StoreError) -> StorageType +} + +extension Int: StoreConvertible { + static func fromValue(_ value: Int) -> Int { value } + static func toValue(_ value: Int) -> Int { value } +} + +extension Bool: StoreConvertible { + static func fromValue(_ value: Int) -> Bool { value == 1 } + static func toValue(_ value: Bool) -> Int { value ? 1 : 0 } +} + +extension Date: StoreConvertible { + static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) } + static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) } +} + +extension String: StoreConvertible { + static func fromValue(_ value: String) -> String { value } + static func toValue(_ value: String) -> String { value } +} + +extension URL: StoreConvertible { + static func fromValue(_ value: String) throws(StoreError) -> URL { + guard let url = URL(string: value) else { + throw StoreError.invalidURL(value) + } + return url + } + static func toValue(_ value: URL) -> String { value.absoluteString } +} + +extension StoreConvertible where Self: Codable, StorageType == String { + static var jsonDecoder: JSONDecoder { JSONDecoder() } + static var jsonEncoder: JSONEncoder { JSONEncoder() } + + static func fromValue(_ value: String) throws(StoreError) -> Self { + do { + return try jsonDecoder.decode(Self.self, from: Data(value.utf8)) + } catch { + throw StoreError.invalidJSON(value) + } + } + + static func toValue(_ value: Self) throws(StoreError) -> String { + let encoded: Data + do { + encoded = try jsonEncoder.encode(value) + } catch { + throw StoreError.encodingFailed + } + + guard let string = String(data: encoded, encoding: .utf8) else { + throw StoreError.encodingFailed + } + return string + } +} + +extension Array: StoreConvertible where Element: Codable { + typealias StorageType = String +} + +extension Dictionary: StoreConvertible where Key == String, Value: Codable { + typealias StorageType = String +} + +class StoreRepository { + private let db: DatabasePool + + init(db: DatabasePool) { + self.db = db + } + + func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == Int { + let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } + if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == String { + let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } + if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == Int { + let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } + if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == String { + let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } + if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == Int { + let value = try T.toValue(value) + try db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == String { + let value = try T.toValue(value) + try db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == Int { + let value = try T.toValue(value) + try await db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == String { + let value = try T.toValue(value) + try await db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) + } + } +} diff --git a/mobile/ios/Runner/Schemas/Tables.swift b/mobile/ios/Runner/Schemas/Tables.swift new file mode 100644 index 0000000000..c256b0d0ed --- /dev/null +++ b/mobile/ios/Runner/Schemas/Tables.swift @@ -0,0 +1,237 @@ +import GRDB +import SQLiteData + +@Table("asset_face_entity") +struct AssetFace { + let id: String + let assetId: String + let personId: String? + let imageWidth: Int + let imageHeight: Int + let boundingBoxX1: Int + let boundingBoxY1: Int + let boundingBoxX2: Int + let boundingBoxY2: Int + let sourceType: String +} + +@Table("auth_user_entity") +struct AuthUser { + let id: String + let name: String + let email: String + let isAdmin: Bool + let hasProfileImage: Bool + let profileChangedAt: Date + let avatarColor: AvatarColor + let quotaSizeInBytes: Int + let quotaUsageInBytes: Int + let pinCode: String? +} + +@Table("local_album_entity") +struct LocalAlbum { + let id: String + let backupSelection: BackupSelection + let linkedRemoteAlbumId: String? + let marker_: Bool? + let name: String + let isIosSharedAlbum: Bool + let updatedAt: Date +} + +@Table("local_album_asset_entity") +struct LocalAlbumAsset { + let id: ID + let marker_: String? + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("local_asset_entity") +struct LocalAsset { + let id: String + let checksum: String? + let createdAt: Date + let durationInSeconds: Int? + let height: Int? + let isFavorite: Bool + let name: String + let orientation: String + let type: Int + let updatedAt: Date + let width: Int? +} + +@Table("memory_asset_entity") +struct MemoryAsset { + let id: ID + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("memory_entity") +struct Memory { + let id: String + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? + let ownerId: String + let type: MemoryType + let data: String + let isSaved: Bool + let memoryAt: Date + let seenAt: Date? + let showAt: Date? + let hideAt: Date? +} + +@Table("partner_entity") +struct Partner { + let id: ID + let inTimeline: Bool + + @Selection + struct ID { + let sharedById: String + let sharedWithId: String + } +} + +@Table("person_entity") +struct Person { + let id: String + let createdAt: Date + let updatedAt: Date + let ownerId: String + let name: String + let faceAssetId: String? + let isFavorite: Bool + let isHidden: Bool + let color: String? + let birthDate: Date? +} + +@Table("remote_album_entity") +struct RemoteAlbum { + let id: String + let createdAt: Date + let description: String? + let isActivityEnabled: Bool + let name: String + let order: Int + let ownerId: String + let thumbnailAssetId: String? + let updatedAt: Date +} + +@Table("remote_album_asset_entity") +struct RemoteAlbumAsset { + let id: ID + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("remote_album_user_entity") +struct RemoteAlbumUser { + let id: ID + let role: AlbumUserRole + + @Selection + struct ID { + let albumId: String + let userId: String + } +} + +@Table("remote_asset_entity") +struct RemoteAsset { + let id: String + let checksum: String? + let deletedAt: Date? + let isFavorite: Int + let libraryId: String? + let livePhotoVideoId: String? + let localDateTime: Date? + let orientation: String + let ownerId: String + let stackId: String? + let visibility: Int +} + +@Table("remote_exif_entity") +struct RemoteExif { + @Column(primaryKey: true) + let assetId: String + let city: String? + let state: String? + let country: String? + let dateTimeOriginal: Date? + let description: String? + let height: Int? + let width: Int? + let exposureTime: String? + let fNumber: Double? + let fileSize: Int? + let focalLength: Double? + let latitude: Double? + let longitude: Double? + let iso: Int? + let make: String? + let model: String? + let lens: String? + let orientation: String? + let timeZone: String? + let rating: Int? + let projectionType: String? +} + +@Table("stack_entity") +struct Stack { + let id: String + let createdAt: Date + let updatedAt: Date + let ownerId: String + let primaryAssetId: String +} + +@Table("store_entity") +struct Store { + let id: StoreKey + let stringValue: String? + let intValue: Int? +} + +@Table("user_entity") +struct User { + let id: String + let name: String + let email: String + let hasProfileImage: Bool + let profileChangedAt: Date + let avatarColor: AvatarColor +} + +@Table("user_metadata_entity") +struct UserMetadata { + let id: ID + let value: Data + + @Selection + struct ID { + let userId: String + let key: Date + } +} From dea95ac2e6ee3e41441886ca59376200978c1b55 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 10 Nov 2025 15:49:02 -0500 Subject: [PATCH 50/93] refactor: shared-link service (#23770) --- pnpm-lock.yaml | 18 ++--- web/package.json | 2 +- web/src/lib/components/ActionButton.svelte | 19 +++++ .../album-page/album-shared-link.svelte | 20 ++--- .../search-bar/search-bar.svelte | 2 +- .../actions/shared-link-copy.svelte | 28 ------- .../actions/shared-link-delete.svelte | 26 ------ .../actions/shared-link-edit.svelte | 33 -------- .../sharedlinks-page/shared-link-card.svelte | 30 +++---- web/src/lib/managers/event-manager.svelte.ts | 1 + web/src/lib/services/shared-link.service.ts | 80 +++++++++++++++++-- .../shared-links/[[id=id]]/+page.svelte | 32 ++------ 12 files changed, 128 insertions(+), 163 deletions(-) create mode 100644 web/src/lib/components/ActionButton.svelte delete mode 100644 web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte delete mode 100644 web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte delete mode 100644 web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da1dd18d97..00087e91e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -684,8 +684,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.40.2 - version: 0.40.2(@internationalized/date@3.8.2)(svelte@5.43.0) + specifier: ^0.43.0 + version: 0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -2776,13 +2776,13 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} - '@immich/svelte-markdown-preprocess@0.0.1': - resolution: {integrity: sha512-1vWoT4LO6fEyxrKwLKiNFECEkRVbuvpYPDvA7LavObTt2ijnonPYBDgfTwCPTofjxcocIGYUayv3CzgOzFiMOA==} + '@immich/svelte-markdown-preprocess@0.1.0': + resolution: {integrity: sha512-jgSOJEGLPKEXQCNRI4r4YUayeM2b0ZYLdzgKGl891jZBhOQIetlY7rU44kPpV1AA3/8wGDwNFKduIQZZ/qJYzg==} peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.40.2': - resolution: {integrity: sha512-6NS4yVx0VoyH+AaM7TISDaoIzZe3RuDOi6xMkK2LrOPQbKwTuheD2iagxsRYzUtJ9IPrmCPrwRBc9Jq5BkvmBQ==} + '@immich/ui@0.43.0': + resolution: {integrity: sha512-dwWIURsGghsbeFnqxCqUyWslyRU2vQjih7uewNr0nsW68bJ5/esl+V/Kiw2opiNiwI4Q3HEcuTRY57k4Hq+X3Q==} peerDependencies: svelte: ^5.0.0 @@ -14346,13 +14346,13 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.0.1(svelte@5.43.0)': + '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.43.0)': dependencies: svelte: 5.43.0 - '@immich/ui@0.40.2(@internationalized/date@3.8.2)(svelte@5.43.0)': + '@immich/ui@0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0)': dependencies: - '@immich/svelte-markdown-preprocess': 0.0.1(svelte@5.43.0) + '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.0) '@mdi/js': 7.4.47 bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.43.0) luxon: 3.7.2 diff --git a/web/package.json b/web/package.json index b9ec394b5d..2a93230c24 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.40.2", + "@immich/ui": "^0.43.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte new file mode 100644 index 0000000000..1bbbf642e0 --- /dev/null +++ b/web/src/lib/components/ActionButton.svelte @@ -0,0 +1,19 @@ + + + onSelect?.({ event, item: action })} +/> diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte index 580a865ae6..1b6db6ff69 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -1,10 +1,9 @@
@@ -40,14 +41,7 @@ {getShareProperties()}
- handleShowSharedLinkQrCode(sharedLink)} - /> - + +
diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 8c1d2c5d08..ea1147fe06 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -92,7 +92,7 @@ } const result = modalManager.open(SearchFilterModal, { searchQuery }); - close = () => result.close(undefined); + close = () => result.close(); closeDropdown(); const searchResult = await result.onClose; diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte deleted file mode 100644 index 06369e7792..0000000000 --- a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -{#if menuItem} - handleCopySharedLinkUrl(sharedLink)} /> -{:else} - handleCopySharedLinkUrl(sharedLink)} - /> -{/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte deleted file mode 100644 index f844d8483e..0000000000 --- a/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -{#if menuItem} - -{:else} - -{/if} diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte deleted file mode 100644 index 7482c1b6eb..0000000000 --- a/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -{#if menuItem} - -{:else} - -{/if} diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index f811f60e77..b2c6cf296d 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -1,23 +1,19 @@
- - - - - +
diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 44ee524658..e7d50f026d 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -10,6 +10,7 @@ export type Events = { ThemeChange: [ThemeSetting]; SharedLinkCreate: [SharedLinkResponseDto]; SharedLinkUpdate: [SharedLinkResponseDto]; + SharedLinkDelete: [SharedLinkResponseDto]; }; type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index 529b6b23b4..a48f0b5965 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -1,3 +1,5 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import { serverConfig } from '$lib/stores/server-config.store'; @@ -6,15 +8,58 @@ import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { createSharedLink, + removeSharedLink, updateSharedLink, type SharedLinkCreateDto, type SharedLinkEditDto, type SharedLinkResponseDto, } from '@immich/sdk'; -import { modalManager, toastManager } from '@immich/ui'; +import { MenuItemType, menuManager, modalManager, toastManager, type MenuItem } from '@immich/ui'; +import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiDotsVertical, mdiQrcode } from '@mdi/js'; +import type { MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; -const makeSharedLinkUrl = (sharedLink: SharedLinkResponseDto) => { +export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLinkResponseDto) => { + const Edit: MenuItem = { + title: $t('edit_link'), + icon: mdiCircleEditOutline, + onSelect: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`), + }; + + const Delete: MenuItem = { + title: $t('delete_link'), + icon: mdiDelete, + color: 'danger', + onSelect: () => void handleDeleteSharedLink(sharedLink), + }; + + const Copy: MenuItem = { + title: $t('copy_link'), + icon: mdiContentCopy, + onSelect: () => void copyToClipboard(asUrl(sharedLink)), + }; + + const ViewQrCode: MenuItem = { + title: $t('view_qr_code'), + icon: mdiQrcode, + onSelect: () => void handleShowSharedLinkQrCode(sharedLink), + }; + + const ContextMenu: MenuItem = { + title: $t('shared_link_options'), + icon: mdiDotsVertical, + onSelect: ({ event }) => + void menuManager.show({ + target: event.currentTarget as HTMLElement, + position: 'top-right', + items: [Edit, Copy, MenuItemType.Divider, Delete], + }), + }; + + return { Edit, Delete, Copy, ViewQrCode, ContextMenu }; +}; + +const asUrl = (sharedLink: SharedLinkResponseDto) => { const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`; return new URL(path, get(serverConfig).externalDomain || globalThis.location.origin).href; }; @@ -54,11 +99,34 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto, } }; -export const handleShowSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) => { +export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): Promise => { const $t = await getFormatter(); - await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); + + const success = await modalManager.showDialog({ + title: $t('delete_shared_link'), + prompt: $t('confirm_delete_shared_link'), + confirmText: $t('delete'), + }); + + if (!success) { + return false; + } + + try { + await removeSharedLink({ id: sharedLink.id }); + + eventManager.emit('SharedLinkDelete', sharedLink); + + toastManager.success($t('deleted_shared_link')); + + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_delete_shared_link')); + return false; + } }; -export const handleCopySharedLinkUrl = async (sharedLink: SharedLinkResponseDto) => { - await copyToClipboard(makeSharedLinkUrl(sharedLink)); +const handleShowSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) => { + const $t = await getFormatter(); + await modalManager.show(QrCodeModal, { title: $t('view_link'), value: asUrl(sharedLink) }); }; diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 4b63ce71a1..e0239a2a43 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -7,9 +7,7 @@ import { AppRoute } from '$lib/constants'; import GroupTab from '$lib/elements/GroupTab.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; - import { handleError } from '$lib/utils/handle-error'; - import { getAllSharedLinks, removeSharedLink, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; - import { modalManager, toastManager } from '@immich/ui'; + import { getAllSharedLinks, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -31,26 +29,6 @@ await refresh(); }); - const handleDeleteLink = async (id: string) => { - const isConfirmed = await modalManager.showDialog({ - title: $t('delete_shared_link'), - prompt: $t('confirm_delete_shared_link'), - confirmText: $t('delete'), - }); - - if (!isConfirmed) { - return; - } - - try { - await removeSharedLink({ id }); - toastManager.success($t('deleted_shared_link')); - await refresh(); - } catch (error) { - handleError(error, $t('errors.unable_to_delete_shared_link')); - } - }; - type Filter = 'all' | 'album' | 'individual'; const filterMap: Record = { @@ -87,9 +65,13 @@ sharedLinks[index] = sharedLink; } }; + + const onSharedLinkDelete = (sharedLink: SharedLinkResponseDto) => { + sharedLinks = sharedLinks.filter(({ id }) => id !== sharedLink.id); + }; - + {#snippet buttons()} @@ -108,7 +90,7 @@ {:else}
{#each filteredSharedLinks as sharedLink (sharedLink.id)} - handleDeleteLink(sharedLink.id)} /> + {/each}
{/if} From d5c5bdffcb2710dae78a5a4de59d4d0982d50bbd Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 10 Nov 2025 16:10:29 -0500 Subject: [PATCH 51/93] refactor: album delete (#23773) --- .../components/album-page/albums-list.svelte | 45 +++++-------------- web/src/lib/managers/event-manager.svelte.ts | 5 ++- web/src/lib/services/album.service.ts | 39 +++++++++++++++- .../[[assetId=id]]/+page.svelte | 39 +++++++--------- 4 files changed, 69 insertions(+), 59 deletions(-) diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index fb8d044b1a..bb826110b7 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -3,6 +3,7 @@ import { resolve } from '$app/paths'; import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte'; import AlbumsTable from '$lib/components/album-page/albums-table.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte'; import ToastAction from '$lib/components/ToastAction.svelte'; @@ -10,7 +11,7 @@ import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; - import { handleConfirmAlbumDelete, handleDownloadAlbum } from '$lib/services/album.service'; + import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service'; import { AlbumFilter, AlbumGroupBy, @@ -26,7 +27,7 @@ import type { ContextMenuPosition } from '$lib/utils/context-menu'; import { handleError } from '$lib/utils/handle-error'; import { normalizeSearchString } from '$lib/utils/string-utils'; - import { addUsersToAlbum, deleteAlbum, isHttpError, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk'; + import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk'; import { modalManager, toastManager } from '@immich/ui'; import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js'; import { groupBy } from 'lodash-es'; @@ -210,25 +211,6 @@ isOpen = false; }; - const handleDeleteAlbum = async (albumToDelete: AlbumResponseDto) => { - try { - await deleteAlbum({ - id: albumToDelete.id, - }); - } catch (error) { - // In rare cases deleting an album completes after the list of albums has been requested, - // leading to a bad request error. - // Since the album is already deleted, the error is ignored. - const isBadRequest = isHttpError(error) && error.status === 400; - if (!isBadRequest) { - throw error; - } - } - - ownedAlbums = ownedAlbums.filter(({ id }) => id !== albumToDelete.id); - sharedAlbums = sharedAlbums.filter(({ id }) => id !== albumToDelete.id); - }; - const handleSelect = async (action: 'edit' | 'share' | 'download' | 'delete') => { closeAlbumContextMenu(); @@ -272,17 +254,7 @@ } case 'delete': { - const isConfirmed = await handleConfirmAlbumDelete(selectedAlbum); - if (!isConfirmed) { - return; - } - - try { - await handleDeleteAlbum(selectedAlbum); - } catch (error) { - handleError(error, $t('errors.unable_to_delete_album')); - } - + await handleDeleteAlbum(selectedAlbum); break; } } @@ -290,7 +262,7 @@ const removeAlbumsIfEmpty = async () => { const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName); - await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album))); + await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false }))); }; const updateAlbumInfo = (album: AlbumResponseDto) => { @@ -346,8 +318,15 @@ albumToShare = null; } }; + + const onAlbumDelete = (album: AlbumResponseDto) => { + ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id); + sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id); + }; + + {#if albums.length > 0} {#if userSettings.view === AlbumViewMode.Cover} diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index e7d50f026d..1bd437b4cc 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,5 +1,5 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; -import type { LoginResponseDto, SharedLinkResponseDto } from '@immich/sdk'; +import type { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@immich/sdk'; export type Events = { AppInit: []; @@ -8,6 +8,9 @@ export type Events = { AuthLogout: []; LanguageChange: [{ name: string; code: string; rtl?: boolean }]; ThemeChange: [ThemeSetting]; + + AlbumDelete: [AlbumResponseDto]; + SharedLinkCreate: [SharedLinkResponseDto]; SharedLinkUpdate: [SharedLinkResponseDto]; SharedLinkDelete: [SharedLinkResponseDto]; diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index 52fa09d103..6e5583495f 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -1,7 +1,42 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; import { downloadArchive } from '$lib/utils/asset-utils'; +import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; -import type { AlbumResponseDto } from '@immich/sdk'; -import { modalManager } from '@immich/ui'; +import { deleteAlbum, type AlbumResponseDto } from '@immich/sdk'; +import { modalManager, toastManager } from '@immich/ui'; + +export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { prompt?: boolean; notify?: boolean }) => { + const $t = await getFormatter(); + const { prompt = true, notify = true } = options ?? {}; + + if (prompt) { + const confirmation = + album.albumName.length > 0 + ? $t('album_delete_confirmation', { values: { album: album.albumName } }) + : $t('unnamed_album_delete_confirmation'); + const description = $t('album_delete_confirmation_description'); + + const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` }); + if (!success) { + return false; + } + } + + try { + await deleteAlbum({ id: album.id }); + + eventManager.emit('AlbumDelete', album); + + if (notify) { + toastManager.success(); + } + + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_delete_album')); + return false; + } +}; export const handleDownloadAlbum = async (album: AlbumResponseDto) => { await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }); 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 6b543e1ecf..9af2b611f2 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 @@ -36,7 +36,7 @@ import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; - import { handleConfirmAlbumDelete, handleDownloadAlbum } from '$lib/services/album.service'; + import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { featureFlags } from '$lib/stores/server-config.store'; @@ -59,9 +59,9 @@ AssetVisibility, addAssetsToAlbum, addUsersToAlbum, - deleteAlbum, getAlbumInfo, updateAlbumInfo, + type AlbumResponseDto, type AlbumUserAddDto, } from '@immich/sdk'; import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; @@ -233,24 +233,6 @@ } }; - const handleRemoveAlbum = async () => { - const isConfirmed = await handleConfirmAlbumDelete(album); - - if (!isConfirmed) { - viewMode = AlbumPageViewMode.VIEW; - return; - } - - try { - await deleteAlbum({ id: album.id }); - await goto(backUrl); - } catch (error) { - handleError(error, $t('errors.unable_to_delete_album')); - } finally { - viewMode = AlbumPageViewMode.VIEW; - } - }; - const handleSetVisibility = (assetIds: string[]) => { timelineManager.removeAssets(assetIds); assetInteraction.clearMultiselect(); @@ -301,7 +283,7 @@ onNavigate(async ({ to }) => { if (!isAlbumsRoute(to?.route.id) && album.assetCount === 0 && !album.albumName) { - await deleteAlbum(album); + await handleDeleteAlbum(album, { notify: false, prompt: false }); } }); @@ -388,6 +370,13 @@ await refreshAlbum(); }; + const onAlbumDelete = async ({ id }: AlbumResponseDto) => { + if (id === album.id) { + await goto(backUrl); + viewMode = AlbumPageViewMode.VIEW; + } + }; + const handleShareLink = async () => { await modalManager.show(SharedLinkCreateModal, { albumId: album.id }); }; @@ -424,7 +413,7 @@ }; - +
@@ -672,7 +661,11 @@ {/if} - handleRemoveAlbum()} /> + handleDeleteAlbum(album)} + /> {/if} From 0b487897a4b576c3c1461a88b9bc9406a9ffcd5a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 10 Nov 2025 16:17:18 -0500 Subject: [PATCH 52/93] refactor: shared link service (#23775) --- .../actions/RemoveFromSharedLinkAction.svelte | 45 ++++--------------- web/src/lib/services/shared-link.service.ts | 39 ++++++++++++++++ 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte b/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte index 973760ac45..774ce8ab45 100644 --- a/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte +++ b/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte @@ -1,9 +1,8 @@ @@ -57,6 +28,6 @@ color="secondary" variant="ghost" aria-label={$t('remove_from_shared_link')} - onclick={handleRemove} + onclick={handleSelect} icon={mdiDeleteOutline} /> diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index a48f0b5965..97a7f5851e 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -1,5 +1,6 @@ import { goto } from '$app/navigation'; import { AppRoute } from '$lib/constants'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import { serverConfig } from '$lib/stores/server-config.store'; @@ -9,6 +10,7 @@ import { getFormatter } from '$lib/utils/i18n'; import { createSharedLink, removeSharedLink, + removeSharedLinkAssets, updateSharedLink, type SharedLinkCreateDto, type SharedLinkEditDto, @@ -126,6 +128,43 @@ export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): } }; +export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkResponseDto, assetIds: string[]) => { + const $t = await getFormatter(); + + const success = await modalManager.showDialog({ + title: $t('remove_assets_title'), + prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }), + confirmText: $t('remove'), + }); + + if (!success) { + return false; + } + + try { + const results = await removeSharedLinkAssets({ + ...authManager.params, + id: sharedLink.id, + assetIdsDto: { assetIds }, + }); + + for (const result of results) { + if (!result.success) { + continue; + } + + sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId); + } + + const count = results.filter((item) => item.success).length; + toastManager.success($t('assets_removed_count', { values: { count } })); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_remove_assets_from_shared_link')); + return false; + } +}; + const handleShowSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) => { const $t = await getFormatter(); await modalManager.show(QrCodeModal, { title: $t('view_link'), value: asUrl(sharedLink) }); From 433a3cd3397385fc369b19cdb50ef2a847bb9ad1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:50:50 -0500 Subject: [PATCH 53/93] chore(deps): update dependency @types/node to ^22.19.0 (#23786) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- server/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index 83be681284..6fed806003 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.18.13", + "@types/node": "^22.19.0", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index b94fd7f6e2..84e1823e0c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^22.18.13", + "@types/node": "^22.19.0", "@types/oidc-provider": "^9.0.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index a751bb7d1b..1756675ddd 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.18.13", + "@types/node": "^22.19.0", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00087e91e0..ad09af2715 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^22.18.13 + specifier: ^22.19.0 version: 22.19.0 '@vitest/coverage-v8': specifier: ^3.0.0 @@ -211,7 +211,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^22.18.13 + specifier: ^22.19.0 version: 22.19.0 '@types/oidc-provider': specifier: ^9.0.0 @@ -293,7 +293,7 @@ importers: version: 1.0.4 devDependencies: '@types/node': - specifier: ^22.18.13 + specifier: ^22.19.0 version: 22.19.0 typescript: specifier: ^5.3.3 @@ -582,7 +582,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^22.18.13 + specifier: ^22.19.0 version: 22.19.0 '@types/nodemailer': specifier: ^7.0.0 diff --git a/server/package.json b/server/package.json index 5b36efb2c4..0a8130163e 100644 --- a/server/package.json +++ b/server/package.json @@ -129,7 +129,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^22.18.13", + "@types/node": "^22.19.0", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From 2611e2ec2048ca69680c8c78c58a310c4321ba18 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 13:35:36 +0100 Subject: [PATCH 54/93] chore(deps): update dependency exiftool-vendored to v31.3.0 (#23787) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad09af2715..d82485c5cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,7 +239,7 @@ importers: version: 60.0.0(eslint@9.38.0(jiti@2.6.1)) exiftool-vendored: specifier: ^31.1.0 - version: 31.1.0 + version: 31.3.0 globals: specifier: ^16.0.0 version: 16.4.0 @@ -405,7 +405,7 @@ importers: version: 4.3.3 exiftool-vendored: specifier: ^31.1.0 - version: 31.1.0 + version: 31.3.0 express: specifier: ^5.1.0 version: 5.1.0 @@ -3550,8 +3550,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.0 - '@photostructure/tz-lookup@11.2.1': - resolution: {integrity: sha512-ugPtvpdLwGQ8IWezSGFgUCYOpO/XXetfKLNv+UN2jjTYyfIDq9dA21GydGyzXuoQ06nN3VGBd3JxmEu+ZtXScg==} + '@photostructure/tz-lookup@11.3.0': + resolution: {integrity: sha512-rYGy7ETBHTnXrwbzm47e3LJPKJmzpY7zXnbZhdosNU0lTGWVqzxptSjK4qZkJ1G+Kwy4F6XStNR9ZqMsXAoASQ==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -6627,17 +6627,17 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.38.0: - resolution: {integrity: sha512-oZx5enTAvSiIAXL+OEk7nNWrfUhEdKUpaGwDjCmz4VKwOa4HbisqyM808xPGPYj8X7XikcME/fq5hvevPeE3cw==} + exiftool-vendored.exe@13.41.0: + resolution: {integrity: sha512-7XG0PjZCm8HVsVUQAD4b/eBtvYBuGkySf2qslqHlnSR6jU1xoD1AgEprb2bCPqwhw0Jn3xzZoo/ihDo4F6fMyA==} os: [win32] - exiftool-vendored.pl@13.38.0: - resolution: {integrity: sha512-Q3xl1nnwswrsR5344z4NyqvI74fKwla+VJHY1N+32gcDgt8cs9KBsDUwcNzKHSOSa/MjEfniuCJVrQiqR05iag==} + exiftool-vendored.pl@13.41.0: + resolution: {integrity: sha512-JqqRuB8TggIOC983oTnOunB/baseGYw8XCkn7ylFGOmEv7oTQAK3uUTZV76vXE1X6c5H6IdHYt0abSgi8Kzc4g==} os: ['!win32'] hasBin: true - exiftool-vendored@31.1.0: - resolution: {integrity: sha512-q8StxLawHLDvhqv/uoBYCfVbDskn49Cr5ouNCZhh4lgryGu1aymHwK9AvO6RcW2SbPm5MSnQDJOfGp2MW5Nnrw==} + exiftool-vendored@31.3.0: + resolution: {integrity: sha512-JQeyRvh7cV81fm9eKej2btdVh2z2Ak/sx89c4OCykeQnhnI81hk9TTraBtborYA+WcLM20cwYMPmpaW/sMy5Qw==} engines: {node: '>=20.0.0'} expect-type@1.2.1: @@ -15300,7 +15300,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.0 three: 0.180.0 - '@photostructure/tz-lookup@11.2.1': {} + '@photostructure/tz-lookup@11.3.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -18807,21 +18807,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.38.0: + exiftool-vendored.exe@13.41.0: optional: true - exiftool-vendored.pl@13.38.0: {} + exiftool-vendored.pl@13.41.0: {} - exiftool-vendored@31.1.0: + exiftool-vendored@31.3.0: dependencies: - '@photostructure/tz-lookup': 11.2.1 + '@photostructure/tz-lookup': 11.3.0 '@types/luxon': 3.7.1 batch-cluster: 15.0.1 - exiftool-vendored.pl: 13.38.0 + exiftool-vendored.pl: 13.41.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.38.0 + exiftool-vendored.exe: 13.41.0 expect-type@1.2.1: {} From 2f40f5aad85c93e4ddeae7e4c3f88f96997049ae Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 11 Nov 2025 07:42:33 -0500 Subject: [PATCH 55/93] refactor: user admin service (#23785) --- e2e/src/web/specs/user-admin.e2e-spec.ts | 4 +- web/src/lib/components/ActionButton.svelte | 19 +- web/src/lib/components/HeaderButton.svelte | 18 ++ web/src/lib/components/TableButton.svelte | 16 ++ web/src/lib/managers/event-manager.svelte.ts | 7 +- web/src/lib/modals/UserCreateModal.svelte | 49 ++-- .../lib/modals/UserDeleteConfirmModal.svelte | 20 +- web/src/lib/modals/UserEditModal.svelte | 38 ++- .../lib/modals/UserRestoreConfirmModal.svelte | 54 ++-- web/src/lib/services/album.service.ts | 4 - web/src/lib/services/shared-link.service.ts | 7 - web/src/lib/services/user-admin.service.ts | 232 ++++++++++++++++++ web/src/lib/types.ts | 4 + web/src/routes/admin/users/+page.svelte | 96 ++------ web/src/routes/admin/users/[id]/+page.svelte | 160 ++---------- 15 files changed, 395 insertions(+), 333 deletions(-) create mode 100644 web/src/lib/components/HeaderButton.svelte create mode 100644 web/src/lib/components/TableButton.svelte create mode 100644 web/src/lib/services/user-admin.service.ts create mode 100644 web/src/lib/types.ts diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts index 3d64e47aef..611a1b3dec 100644 --- a/e2e/src/web/specs/user-admin.e2e-spec.ts +++ b/e2e/src/web/specs/user-admin.e2e-spec.ts @@ -52,7 +52,7 @@ test.describe('User Administration', () => { await page.goto(`/admin/users/${user.userId}`); - await page.getByRole('button', { name: 'Edit user' }).click(); + await page.getByRole('button', { name: 'Edit' }).click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); await page.getByText('Admin User').click(); await expect(page.getByLabel('Admin User')).toBeChecked(); @@ -77,7 +77,7 @@ test.describe('User Administration', () => { await page.goto(`/admin/users/${user.userId}`); - await page.getByRole('button', { name: 'Edit user' }).click(); + await page.getByRole('button', { name: 'Edit' }).click(); await expect(page.getByLabel('Admin User')).toBeChecked(); await page.getByText('Admin User').click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte index 1bbbf642e0..37bc09fb73 100644 --- a/web/src/lib/components/ActionButton.svelte +++ b/web/src/lib/components/ActionButton.svelte @@ -1,19 +1,16 @@ - onSelect?.({ event, item: action })} -/> +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/components/HeaderButton.svelte b/web/src/lib/components/HeaderButton.svelte new file mode 100644 index 0000000000..9021d2d1cb --- /dev/null +++ b/web/src/lib/components/HeaderButton.svelte @@ -0,0 +1,18 @@ + + +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte new file mode 100644 index 0000000000..4bd82e4dd9 --- /dev/null +++ b/web/src/lib/components/TableButton.svelte @@ -0,0 +1,16 @@ + + +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 1bd437b4cc..733fb8bc71 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,5 +1,5 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; -import type { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@immich/sdk'; +import type { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto, UserAdminResponseDto } from '@immich/sdk'; export type Events = { AppInit: []; @@ -14,6 +14,11 @@ export type Events = { SharedLinkCreate: [SharedLinkResponseDto]; SharedLinkUpdate: [SharedLinkResponseDto]; SharedLinkDelete: [SharedLinkResponseDto]; + + UserAdminCreate: [UserAdminResponseDto]; + UserAdminUpdate: [UserAdminResponseDto]; + UserAdminDelete: [UserAdminResponseDto]; + UserAdminRestore: [UserAdminResponseDto]; }; type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/lib/modals/UserCreateModal.svelte index 1a1f46d1d5..38d6ed54b2 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/lib/modals/UserCreateModal.svelte @@ -1,11 +1,9 @@
- {#if error} - - {/if} - {#if success}

{$t('new_user_created')}

{/if} diff --git a/web/src/lib/modals/UserDeleteConfirmModal.svelte b/web/src/lib/modals/UserDeleteConfirmModal.svelte index 9c9223707e..46e02d54c4 100644 --- a/web/src/lib/modals/UserDeleteConfirmModal.svelte +++ b/web/src/lib/modals/UserDeleteConfirmModal.svelte @@ -1,14 +1,15 @@ import { AppRoute } from '$lib/constants'; + import { handleUpdateUserAdmin } from '$lib/services/user-admin.service'; import { user as authUser } from '$lib/stores/user.store'; import { userInteraction } from '$lib/stores/user.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; - import { handleError } from '$lib/utils/handle-error'; - import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; + import { type UserAdminResponseDto } from '@immich/sdk'; import { Button, Field, @@ -23,7 +23,7 @@ interface Props { user: UserAdminResponseDto; - onClose: (data?: UserAdminResponseDto) => void; + onClose: () => void; } let { user, onClose }: Props = $props(); @@ -48,28 +48,20 @@ quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw, ); - const handleEditUser = async () => { - try { - const newUser = await updateUserAdmin({ - id: user.id, - userAdminUpdateDto: { - email, - name, - storageLabel, - quotaSizeInBytes: typeof quotaSize === 'number' ? convertToBytes(quotaSize, ByteUnit.GiB) : null, - isAdmin, - }, - }); - - onClose(newUser); - } catch (error) { - handleError(error, $t('errors.unable_to_update_user')); - } - }; - const onSubmit = async (event: Event) => { event.preventDefault(); - await handleEditUser(); + + const success = await handleUpdateUserAdmin(user, { + email, + name, + storageLabel, + quotaSizeInBytes: typeof quotaSize === 'number' ? convertToBytes(quotaSize, ByteUnit.GiB) : null, + isAdmin, + }); + + if (success) { + onClose(); + } }; diff --git a/web/src/lib/modals/UserRestoreConfirmModal.svelte b/web/src/lib/modals/UserRestoreConfirmModal.svelte index 03c36e27cd..0a01f846b9 100644 --- a/web/src/lib/modals/UserRestoreConfirmModal.svelte +++ b/web/src/lib/modals/UserRestoreConfirmModal.svelte @@ -1,30 +1,39 @@ - - + + {#snippet promptSnippet()}

{#snippet children({ message })} @@ -32,16 +41,5 @@ {/snippet}

-
- - - - - - - -
+ {/snippet} +
diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index 6e5583495f..702f84d6f9 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -15,7 +15,6 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro ? $t('album_delete_confirmation', { values: { album: album.albumName } }) : $t('unnamed_album_delete_confirmation'); const description = $t('album_delete_confirmation_description'); - const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` }); if (!success) { return false; @@ -24,13 +23,10 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro try { await deleteAlbum({ id: album.id }); - eventManager.emit('AlbumDelete', album); - if (notify) { toastManager.success(); } - return true; } catch (error) { handleError(error, $t('errors.unable_to_delete_album')); diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index 97a7f5851e..9ac1c47a94 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -103,24 +103,19 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto, export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): Promise => { const $t = await getFormatter(); - const success = await modalManager.showDialog({ title: $t('delete_shared_link'), prompt: $t('confirm_delete_shared_link'), confirmText: $t('delete'), }); - if (!success) { return false; } try { await removeSharedLink({ id: sharedLink.id }); - eventManager.emit('SharedLinkDelete', sharedLink); - toastManager.success($t('deleted_shared_link')); - return true; } catch (error) { handleError(error, $t('errors.unable_to_delete_shared_link')); @@ -130,13 +125,11 @@ export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkResponseDto, assetIds: string[]) => { const $t = await getFormatter(); - const success = await modalManager.showDialog({ title: $t('remove_assets_title'), prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }), confirmText: $t('remove'), }); - if (!success) { return false; } diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts new file mode 100644 index 0000000000..9d3e21bc9e --- /dev/null +++ b/web/src/lib/services/user-admin.service.ts @@ -0,0 +1,232 @@ +import { goto } from '$app/navigation'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte'; +import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; +import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; +import UserEditModal from '$lib/modals/UserEditModal.svelte'; +import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte'; +import { serverConfig } from '$lib/stores/server-config.store'; +import { user as authUser } from '$lib/stores/user.store'; +import type { ActionItem } from '$lib/types'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { + createUserAdmin, + deleteUserAdmin, + restoreUserAdmin, + updateUserAdmin, + UserStatus, + type UserAdminCreateDto, + type UserAdminDeleteDto, + type UserAdminResponseDto, + type UserAdminUpdateDto, +} from '@immich/sdk'; +import { MenuItemType, menuManager, modalManager, toastManager } from '@immich/ui'; +import { + mdiDeleteRestore, + mdiDotsVertical, + mdiEyeOutline, + mdiLockReset, + mdiLockSmart, + mdiPencilOutline, + mdiPlusBoxOutline, + mdiTrashCanOutline, +} from '@mdi/js'; +import { DateTime } from 'luxon'; +import type { MessageFormatter } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +const getDeleteDate = (deletedAt: string): Date => + DateTime.fromISO(deletedAt) + .plus({ days: get(serverConfig).userDeleteDelay }) + .toJSDate(); + +export const getUserAdminsActions = ($t: MessageFormatter) => { + const Create: ActionItem = { + title: $t('create_user'), + icon: mdiPlusBoxOutline, + onSelect: () => void modalManager.show(UserCreateModal, {}), + }; + + return { Create }; +}; + +export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => { + const View: ActionItem = { + icon: mdiEyeOutline, + title: $t('view'), + onSelect: () => void goto(`/admin/users/${user.id}`), + }; + + const Update: ActionItem = { + icon: mdiPencilOutline, + title: $t('edit'), + onSelect: () => void modalManager.show(UserEditModal, { user }), + }; + + const Delete: ActionItem = { + icon: mdiTrashCanOutline, + title: $t('delete'), + color: 'danger', + $if: () => get(authUser).id !== user.id && !user.deletedAt, + onSelect: () => void modalManager.show(UserDeleteConfirmModal, { user }), + }; + + const Restore: ActionItem = { + icon: mdiDeleteRestore, + title: $t('restore'), + color: 'primary', + $if: () => !!user.deletedAt && user.status === UserStatus.Deleted, + onSelect: () => void modalManager.show(UserRestoreConfirmModal, { user }), + props: { + title: $t('admin.user_restore_scheduled_removal', { + values: { date: getDeleteDate(user.deletedAt!) }, + }), + }, + }; + + const ResetPassword: ActionItem = { + icon: mdiLockReset, + title: $t('reset_password'), + $if: () => get(authUser).id !== user.id, + onSelect: () => void handleResetPasswordUserAdmin(user), + }; + + const ResetPinCode: ActionItem = { + icon: mdiLockSmart, + title: $t('reset_pin_code'), + onSelect: () => void handleResetPinCodeUserAdmin(user), + }; + + const ContextMenu: ActionItem = { + icon: mdiDotsVertical, + title: $t('actions'), + onSelect: ({ event }) => + void menuManager.show({ + target: event.currentTarget as HTMLElement, + position: 'top-right', + items: [ + View, + Update, + ResetPassword, + ResetPinCode, + get(authUser).id === user.id ? undefined : MenuItemType.Divider, + Restore, + Delete, + ].filter(Boolean), + }), + }; + + return { View, Update, Delete, Restore, ResetPassword, ResetPinCode, ContextMenu }; +}; + +export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => { + const $t = await getFormatter(); + + try { + const response = await createUserAdmin({ userAdminCreateDto: dto }); + eventManager.emit('UserAdminCreate', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_create_user')); + } +}; + +export const handleUpdateUserAdmin = async (user: UserAdminResponseDto, dto: UserAdminUpdateDto) => { + const $t = await getFormatter(); + + try { + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_update_user')); + return false; + } +}; + +export const handleDeleteUserAdmin = async (user: UserAdminResponseDto, dto: UserAdminDeleteDto) => { + const $t = await getFormatter(); + + try { + const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: dto }); + eventManager.emit('UserAdminDelete', result); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_delete_user')); + } +}; + +export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + + try { + const response = await restoreUserAdmin({ id: user.id }); + eventManager.emit('UserAdminRestore', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_restore_user')); + return false; + } +}; + +// TODO move password reset server-side +const generatePassword = (length: number = 16) => { + let generatedPassword = ''; + + const characterSet = '0123456789' + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + ',.-{}+!#$%/()=?'; + + for (let i = 0; i < length; i++) { + let randomNumber = crypto.getRandomValues(new Uint32Array(1))[0]; + randomNumber = randomNumber / 2 ** 32; + randomNumber = Math.floor(randomNumber * characterSet.length); + + generatedPassword += characterSet[randomNumber]; + } + + return generatedPassword; +}; + +export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + const prompt = $t('admin.confirm_user_password_reset', { values: { user: user.name } }); + const success = await modalManager.showDialog({ prompt }); + if (!success) { + return false; + } + + try { + const dto = { password: generatePassword(), shouldChangePassword: true }; + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success(); + await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password }); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_reset_password')); + return false; + } +}; + +export const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + const prompt = $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }); + const success = await modalManager.showDialog({ prompt }); + if (!success) { + return false; + } + + try { + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success($t('pin_code_reset_successfully')); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_reset_pin_code')); + return false; + } +}; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000000..960158a0f7 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,4 @@ +import type { MenuItem } from '@immich/ui'; +import type { HTMLAttributes } from 'svelte/elements'; + +export type ActionItem = MenuItem & { props?: Omit, 'color'> }; diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 129862a62c..c4c1012774 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -1,19 +1,16 @@ + + {#snippet buttons()} - + {/snippet}
@@ -93,20 +78,21 @@ {#if allUsers} - {#each allUsers as immichUser (immichUser.id)} + {#each allUsers as user (user.id)} + {@const UserAdminActions = getUserAdminActions($t, user)} - {immichUser.email} + {user.email} - {immichUser.name} + {user.name}
- {#if immichUser.quotaSizeInBytes !== null && immichUser.quotaSizeInBytes >= 0} - {getByteUnitString(immichUser.quotaSizeInBytes, $locale)} + {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} + {getByteUnitString(user.quotaSizeInBytes, $locale)} {:else} {/if} @@ -115,38 +101,8 @@ - {#if !immichUser.deletedAt} - - {#if immichUser.id !== $user.id} - handleDelete(immichUser)} - aria-label={$t('delete_user')} - /> - {/if} - {/if} - {#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted} - handleRestore(immichUser)} - aria-label={$t('admin.user_restore_scheduled_removal')} - /> - {/if} + + {/each} diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 79c663af39..49cfb4715a 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -1,22 +1,18 @@ + + {#snippet buttons()} - {#if canResetPassword} - - {/if} - - - - {#if user.deletedAt} - - {:else} - - {/if} + + + + + {/snippet}
From 0b3633db4f2c6b050475554387e63be03bdf9a6d Mon Sep 17 00:00:00 2001 From: David Wolff Date: Tue, 11 Nov 2025 13:47:11 +0100 Subject: [PATCH 56/93] fix(server): properly handle HEAD requests to SSR paths (#23788) --- server/src/services/api.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 143b470750..ee9b0e622d 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -65,9 +65,10 @@ export class ApiService { } return async (request: Request, res: Response, next: NextFunction) => { + const method = request.method.toLowerCase(); if ( request.url.startsWith('/api') || - request.method.toLowerCase() !== 'get' || + (method !== 'get' && method !== 'head') || excludePaths.some((item) => request.url.startsWith(item)) ) { return next(); From 905f4375b0f402f7c65b0b26611880f86c34ee90 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:50:31 +0100 Subject: [PATCH 57/93] fix(web): make sliding window cover all visible space to show small number of assets (#23796) --- .../shared-components/gallery-viewer/gallery-viewer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 7f87237054..82f876834a 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -99,7 +99,7 @@ let scrollTop = $state(0); let slidingWindow = $derived.by(() => { const top = (scrollTop || 0) - slidingWindowOffset; - const bottom = top + viewport.height; + const bottom = top + viewport.height + slidingWindowOffset; return { top, bottom, From f915d4cc909ab768cf79840a4e4dccc6f3d93128 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Tue, 11 Nov 2025 15:51:21 +0100 Subject: [PATCH 58/93] fix: disable ruby updates (#23794) Until https://github.com/fastlane/fastlane/issues/29183 is fixed --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/renovate.json b/renovate.json index 3a889f4789..fbbc8976bd 100644 --- a/renovate.json +++ b/renovate.json @@ -26,6 +26,12 @@ "matchPackageNames": ["ghcr.io/immich-app/postgres"], "matchUpdateTypes": ["major"], "enabled": false + }, + { + "matchPackageNames": ["ruby"], + "groupName": "ruby", + "matchCurrentVersion": "< 3.4", + "enabled": false } ], "ignorePaths": [ From 2dc81e28fca3a0ee78f87b4593a2bc38eb45f9f7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:25:36 +0100 Subject: [PATCH 59/93] chore(deps): update github-actions (#23582) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 4 ++-- .github/workflows/close-duplicates.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/prepare-release.yml | 2 +- .github/workflows/test.yml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index dae8cec1fd..fc2c9f6853 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -84,7 +84,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 @@ -105,7 +105,7 @@ jobs: - name: Generate docker image tags id: metadata - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 with: flavor: | latest=false diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index ba360b50dc..b3c79f81d8 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -35,7 +35,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723 + image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271 outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3f32478c0c..34228843ad 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 77f32ace4f..4b278d9475 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -62,7 +62,7 @@ jobs: ref: main - name: Install uv - uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1 + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a63046c0e..44d7250f2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -563,7 +563,7 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1 + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # with: From 337e3a8dac4e05de59d42657cc8bc59b1fc410eb Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 12 Nov 2025 01:04:54 +0900 Subject: [PATCH 60/93] feat(mobile): album activity deep link (#23737) * feat: add activity deep link support in DeepLinkService * test: add unit tests for DeepLinkService handling of activity deep links * Revert "test: add unit tests for DeepLinkService handling of activity deep links" This reverts commit 0b1914be9ad03c545fc5a32ee3aecd0181e4dca5. --- mobile/lib/services/deep_link.service.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index d67362aac2..6ede7f6830 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -77,6 +77,7 @@ class DeepLinkService { "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), + "activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''), _ => null, }; @@ -185,4 +186,18 @@ class DeepLinkService { return AlbumViewerRoute(albumId: album.id); } } + + Future _buildActivityDeepLink(String albumId) async { + if (Store.isBetaTimelineEnabled == false) { + return null; + } + + final album = await _betaRemoteAlbumService.get(albumId); + + if (album == null || album.isActivityEnabled == false) { + return null; + } + + return DriftActivitiesRoute(album: album); + } } From 4fd9e42ce52bd78746e75d270fca7276aac0b5ac Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:22:53 +0100 Subject: [PATCH 61/93] feat(web): animate gifs on hover (#23198) --- .../assets/thumbnail/thumbnail.svelte | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 994d741605..dd13d613b2 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,7 +1,7 @@ + +
+ +
diff --git a/web/src/lib/modals/SharedLinkCreateModal.svelte b/web/src/lib/modals/SharedLinkCreateModal.svelte index 5ba0402752..f2fcacd17e 100644 --- a/web/src/lib/modals/SharedLinkCreateModal.svelte +++ b/web/src/lib/modals/SharedLinkCreateModal.svelte @@ -1,23 +1,20 @@ - + {#if shareType === SharedLinkType.Album} - {#if !editingLink} -
{$t('album_with_link_access')}
- {:else} -
- {$t('public_album')} | - {editingLink.album?.albumName} -
- {/if} +
{$t('album_with_link_access')}
{/if} {#if shareType === SharedLinkType.Individual} - {#if !editingLink} -
{$t('create_link_to_share_description')}
- {:else} -
- {$t('individual_share')} | - {editingLink.description || ''} -
- {/if} +
{$t('create_link_to_share_description')}
{/if}
@@ -166,15 +80,7 @@ -
- -
+ @@ -187,20 +93,13 @@ - - {#if editingLink} - - - - {/if}
- {#if editingLink} - - {:else} - - {/if} + + + +
diff --git a/web/src/lib/modals/SharedLinkUpdateModal.svelte b/web/src/lib/modals/SharedLinkUpdateModal.svelte new file mode 100644 index 0000000000..f3bdd42a89 --- /dev/null +++ b/web/src/lib/modals/SharedLinkUpdateModal.svelte @@ -0,0 +1,98 @@ + + + + + {#if shareType === SharedLinkType.Album} +
+ {$t('public_album')} | + {sharedLink.album?.albumName} +
+ {/if} + + {#if shareType === SharedLinkType.Individual} +
+ {$t('individual_share')} | + {sharedLink.description || ''} +
+ {/if} + +
+
+ + + + {#if slug} + /s/{encodeURIComponent(slug)} + {/if} +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index e0239a2a43..d8b35204dc 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -6,7 +6,7 @@ import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte'; import { AppRoute } from '$lib/constants'; import GroupTab from '$lib/elements/GroupTab.svelte'; - import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; + import SharedLinkUpdateModal from '$lib/modals/SharedLinkUpdateModal.svelte'; import { getAllSharedLinks, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -96,7 +96,7 @@ {/if} {#if sharedLink} - goto(AppRoute.SHARED_LINKS)} /> + goto(AppRoute.SHARED_LINKS)} /> {/if}
From c958f9856def7f2afd9406681011a79b548e6022 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:47:44 +0530 Subject: [PATCH 66/93] chore: bump background_downloader (#23839) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/pubspec.lock | 4 ++-- mobile/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0b10384621..59b23f23ca 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: background_downloader - sha256: a22acfa37aa06ba5cfe6eb7b1aa700c78af64770ff450c73dd3d279d7c37d4ac + sha256: a913b37cc47a656a225e9562b69576000d516f705482f392e2663500e6ff6032 url: "https://pub.dev" source: hosted - version: "9.2.6" + version: "9.3.0" bonsoir: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 617516b94a..3dce49e4e1 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -11,7 +11,7 @@ environment: dependencies: async: ^2.13.0 auto_route: ^9.2.0 - background_downloader: ^9.2.6 + background_downloader: ^9.3.0 cached_network_image: ^3.4.1 cancellation_token_http: ^2.1.0 cast: ^2.1.0 From edf21bae41f5a9c03ac146a65b292a6f19e41852 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:19:18 +0100 Subject: [PATCH 67/93] feat(web): disable searching by disabled features (#23798) fix(web): disable searching by disabled features --- .../search-bar/search-text-section.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte index 902d6d79b1..af620bde05 100644 --- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte @@ -1,5 +1,6 @@
diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index d176839543..ff22960b49 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -1,12 +1,13 @@
-
+ event.preventDefault()}>

@@ -75,7 +63,7 @@ label={$t('admin.transcoding_transcode_policy')} {disabled} desc={$t('admin.transcoding_transcode_policy_description')} - bind:value={config.ffmpeg.transcode} + bind:value={configToEdit.ffmpeg.transcode} name="transcode" options={[ { value: TranscodePolicy.All, text: $t('all_videos') }, @@ -96,14 +84,14 @@ text: $t('admin.transcoding_disabled_description'), }, ]} - isEdited={config.ffmpeg.transcode !== savedConfig.ffmpeg.transcode} + isEdited={configToEdit.ffmpeg.transcode !== config.ffmpeg.transcode} /> @@ -121,7 +109,7 @@ label={$t('admin.transcoding_accepted_audio_codecs')} {disabled} desc={$t('admin.transcoding_accepted_audio_codecs_description')} - bind:value={config.ffmpeg.acceptedAudioCodecs} + bind:value={configToEdit.ffmpeg.acceptedAudioCodecs} name="audioCodecs" options={[ { value: AudioCodec.Aac, text: 'AAC' }, @@ -130,8 +118,8 @@ { value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' }, ]} isEdited={!isEqual( + sortBy(configToEdit.ffmpeg.acceptedAudioCodecs), sortBy(config.ffmpeg.acceptedAudioCodecs), - sortBy(savedConfig.ffmpeg.acceptedAudioCodecs), )} /> @@ -139,7 +127,7 @@ label={$t('admin.transcoding_accepted_containers')} {disabled} desc={$t('admin.transcoding_accepted_containers_description')} - bind:value={config.ffmpeg.acceptedContainers} + bind:value={configToEdit.ffmpeg.acceptedContainers} name="videoContainers" options={[ { value: VideoContainer.Mov, text: 'MOV' }, @@ -147,8 +135,8 @@ { value: VideoContainer.Webm, text: 'WebM' }, ]} isEdited={!isEqual( + sortBy(configToEdit.ffmpeg.acceptedContainers), sortBy(config.ffmpeg.acceptedContainers), - sortBy(savedConfig.ffmpeg.acceptedContainers), )} />

@@ -164,7 +152,7 @@ label={$t('admin.transcoding_video_codec')} {disabled} desc={$t('admin.transcoding_video_codec_description')} - bind:value={config.ffmpeg.targetVideoCodec} + bind:value={configToEdit.ffmpeg.targetVideoCodec} options={[ { value: VideoCodec.H264, text: 'h264' }, { value: VideoCodec.Hevc, text: 'hevc' }, @@ -172,8 +160,8 @@ { value: VideoCodec.Av1, text: 'av1' }, ]} name="vcodec" - isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec} - onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} + isEdited={configToEdit.ffmpeg.targetVideoCodec !== config.ffmpeg.targetVideoCodec} + onSelect={() => (configToEdit.ffmpeg.acceptedVideoCodecs = [configToEdit.ffmpeg.targetVideoCodec])} /> @@ -181,25 +169,25 @@ label={$t('admin.transcoding_audio_codec')} {disabled} desc={$t('admin.transcoding_audio_codec_description')} - bind:value={config.ffmpeg.targetAudioCodec} + bind:value={configToEdit.ffmpeg.targetAudioCodec} options={[ { value: AudioCodec.Aac, text: 'aac' }, { value: AudioCodec.Mp3, text: 'mp3' }, { value: AudioCodec.Libopus, text: 'opus' }, ]} name="acodec" - isEdited={config.ffmpeg.targetAudioCodec !== savedConfig.ffmpeg.targetAudioCodec} + isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec} onSelect={() => - config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec) + configToEdit.ffmpeg.acceptedAudioCodecs.includes(configToEdit.ffmpeg.targetAudioCodec) ? null - : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)} + : configToEdit.ffmpeg.acceptedAudioCodecs.push(configToEdit.ffmpeg.targetAudioCodec)} />
@@ -307,7 +295,7 @@ label={$t('admin.transcoding_acceleration_api')} {disabled} desc={$t('admin.transcoding_acceleration_api_description')} - bind:value={config.ffmpeg.accel} + bind:value={configToEdit.ffmpeg.accel} name="accel" options={[ { value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') }, @@ -328,27 +316,27 @@ text: $t('disabled'), }, ]} - isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel} + isEdited={configToEdit.ffmpeg.accel !== config.ffmpeg.accel} /> @@ -356,16 +344,16 @@ title={$t('admin.transcoding_temporal_aq')} {disabled} subtitle={$t('admin.transcoding_temporal_aq_description')} - bind:checked={config.ffmpeg.temporalAQ} - isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ} + bind:checked={configToEdit.ffmpeg.temporalAQ} + isEdited={configToEdit.ffmpeg.temporalAQ !== config.ffmpeg.temporalAQ} />
@@ -381,8 +369,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_max_b_frames')} description={$t('admin.transcoding_max_b_frames_description')} - bind:value={config.ffmpeg.bframes} - isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes} + bind:value={configToEdit.ffmpeg.bframes} + isEdited={configToEdit.ffmpeg.bframes !== config.ffmpeg.bframes} {disabled} /> @@ -390,8 +378,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_reference_frames')} description={$t('admin.transcoding_reference_frames_description')} - bind:value={config.ffmpeg.refs} - isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs} + bind:value={configToEdit.ffmpeg.refs} + isEdited={configToEdit.ffmpeg.refs !== config.ffmpeg.refs} {disabled} /> @@ -399,8 +387,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_max_keyframe_interval')} description={$t('admin.transcoding_max_keyframe_interval_description')} - bind:value={config.ffmpeg.gopSize} - isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize} + bind:value={configToEdit.ffmpeg.gopSize} + isEdited={configToEdit.ffmpeg.gopSize !== config.ffmpeg.gopSize} {disabled} />
@@ -408,12 +396,7 @@
- onReset({ ...options, configKeys: ['ffmpeg'] })} - onSave={() => onSave({ ffmpeg: config.ffmpeg })} - showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig.ffmpeg)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/ImageSettings.svelte b/web/src/lib/components/admin-settings/ImageSettings.svelte index fd2ac29c6b..d63e48d372 100644 --- a/web/src/lib/components/admin-settings/ImageSettings.svelte +++ b/web/src/lib/components/admin-settings/ImageSettings.svelte @@ -1,62 +1,40 @@
-
+ event.preventDefault()}>
@@ -64,7 +42,7 @@ label={$t('admin.image_resolution')} desc={$t('admin.image_resolution_description')} number - bind:value={config.image.thumbnail.size} + bind:value={configToEdit.image.thumbnail.size} options={[ { value: 1080, text: '1080p' }, { value: 720, text: '720p' }, @@ -73,7 +51,7 @@ { value: 200, text: '200p' }, ]} name="resolution" - isEdited={config.image.thumbnail.size !== savedConfig.image.thumbnail.size} + isEdited={configToEdit.image.thumbnail.size !== config.image.thumbnail.size} {disabled} /> @@ -81,8 +59,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.image_quality')} description={$t('admin.image_thumbnail_quality_description')} - bind:value={config.image.thumbnail.quality} - isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality} + bind:value={configToEdit.image.thumbnail.quality} + isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality} {disabled} /> @@ -91,18 +69,17 @@ key="preview-settings" title={$t('admin.image_preview_title')} subtitle={$t('admin.image_preview_description')} - isOpen={openByDefault} > @@ -110,7 +87,7 @@ label={$t('admin.image_resolution')} desc={$t('admin.image_resolution_description')} number - bind:value={config.image.preview.size} + bind:value={configToEdit.image.preview.size} options={[ { value: 2160, text: '4K' }, { value: 1440, text: '1440p' }, @@ -118,7 +95,7 @@ { value: 720, text: '720p' }, ]} name="resolution" - isEdited={config.image.preview.size !== savedConfig.image.preview.size} + isEdited={configToEdit.image.preview.size !== config.image.preview.size} {disabled} /> @@ -126,8 +103,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.image_quality')} description={$t('admin.image_preview_quality_description')} - bind:value={config.image.preview.quality} - isEdited={config.image.preview.quality !== savedConfig.image.preview.quality} + bind:value={configToEdit.image.preview.quality} + isEdited={configToEdit.image.preview.quality !== config.image.preview.quality} {disabled} /> @@ -136,14 +113,13 @@ key="fullsize-settings" title={$t('admin.image_fullsize_title')} subtitle={$t('admin.image_fullsize_description')} - isOpen={openByDefault} > (config.image.fullsize.enabled = isChecked)} - isEdited={config.image.fullsize.enabled !== savedConfig.image.fullsize.enabled} + checked={configToEdit.image.fullsize.enabled} + onToggle={(isChecked) => (configToEdit.image.fullsize.enabled = isChecked)} + isEdited={configToEdit.image.fullsize.enabled !== config.image.fullsize.enabled} {disabled} /> @@ -152,23 +128,23 @@ @@ -176,9 +152,9 @@ (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} - isEdited={config.image.colorspace !== savedConfig.image.colorspace} + checked={configToEdit.image.colorspace === Colorspace.P3} + onToggle={(isChecked) => (configToEdit.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} + isEdited={configToEdit.image.colorspace !== config.image.colorspace} {disabled} />
@@ -187,21 +163,16 @@ (config.image.extractEmbedded = !config.image.extractEmbedded)} - isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} + checked={configToEdit.image.extractEmbedded} + onToggle={() => (configToEdit.image.extractEmbedded = !configToEdit.image.extractEmbedded)} + isEdited={configToEdit.image.extractEmbedded !== config.image.extractEmbedded} {disabled} />
- onReset({ ...options, configKeys: ['image'] })} - onSave={() => onSave({ image: config.image })} - showResetToDefault={!isEqual(savedConfig.image, defaultConfig.image)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/JobSettings.svelte b/web/src/lib/components/admin-settings/JobSettings.svelte index 70de73f81b..5fd7b4117f 100644 --- a/web/src/lib/components/admin-settings/JobSettings.svelte +++ b/web/src/lib/components/admin-settings/JobSettings.svelte @@ -1,24 +1,16 @@
-
+ event.preventDefault()}> {#each jobNames as jobName (jobName)}
{#if isSystemConfigJobDto(jobName)} @@ -55,9 +42,9 @@ {disabled} label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })} description="" - bind:value={config.job[jobName].concurrency} + bind:value={configToEdit.job[jobName].concurrency} required={true} - isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)} + isEdited={!(configToEdit.job[jobName].concurrency == config.job[jobName].concurrency)} /> {:else} - onReset({ ...options, configKeys: ['job'] })} - onSave={() => onSave({ job: config.job })} - showResetToDefault={!isEqual(savedConfig.job, defaultConfig.job)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index 82ce13ae2c..83fc3fdbed 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -1,36 +1,18 @@
-
+ event.preventDefault()}>
diff --git a/web/src/lib/components/admin-settings/LoggingSettings.svelte b/web/src/lib/components/admin-settings/LoggingSettings.svelte index 90bd04d9a6..707689d502 100644 --- a/web/src/lib/components/admin-settings/LoggingSettings.svelte +++ b/web/src/lib/components/admin-settings/LoggingSettings.svelte @@ -1,42 +1,30 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['logging'] })} - onSave={() => onSave({ logging: config.logging })} - showResetToDefault={!isEqual(savedConfig.logging, defaultConfig.logging)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte index e05b5088a4..773d30f05e 100644 --- a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte +++ b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte @@ -1,65 +1,52 @@
-
+ event.preventDefault()}>

- {#each config.machineLearning.urls as _, i (i)} + {#each configToEdit.machineLearning.urls as _, i (i)} {#snippet trailingSnippet()} - {#if config.machineLearning.urls.length > 1} + {#if configToEdit.machineLearning.urls.length > 1} config.machineLearning.urls.splice(i, 1)} + onclick={() => configToEdit.machineLearning.urls.splice(i, 1)} icon={mdiTrashCanOutline} color="danger" /> @@ -75,8 +62,8 @@ size="small" shape="round" leadingIcon={mdiPlus} - onclick={() => config.machineLearning.urls.push('')} - disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')} configToEdit.machineLearning.urls.push('')} + disabled={disabled || !configToEdit.machineLearning.enabled}>{$t('add_url')}
@@ -89,8 +76,8 @@

@@ -98,21 +85,25 @@
@@ -126,8 +117,8 @@
@@ -135,10 +126,10 @@ {#snippet descriptionSnippet()}

@@ -162,8 +153,8 @@


@@ -171,14 +162,14 @@
@@ -192,8 +183,8 @@
@@ -202,54 +193,62 @@ label={$t('admin.machine_learning_facial_recognition_model')} desc={$t('admin.machine_learning_facial_recognition_model_description')} name="facial-recognition-model" - bind:value={config.machineLearning.facialRecognition.modelName} + bind:value={configToEdit.machineLearning.facialRecognition.modelName} options={[ { value: 'antelopev2', text: 'antelopev2' }, { value: 'buffalo_l', text: 'buffalo_l' }, { value: 'buffalo_m', text: 'buffalo_m' }, { value: 'buffalo_s', text: 'buffalo_s' }, ]} - disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} - isEdited={config.machineLearning.facialRecognition.modelName !== - savedConfig.machineLearning.facialRecognition.modelName} + disabled={disabled || + !configToEdit.machineLearning.enabled || + !configToEdit.machineLearning.facialRecognition.enabled} + isEdited={configToEdit.machineLearning.facialRecognition.modelName !== + config.machineLearning.facialRecognition.modelName} />
@@ -263,8 +262,8 @@
@@ -273,7 +272,7 @@ label={$t('admin.machine_learning_ocr_model')} desc={$t('admin.machine_learning_ocr_model_description')} name="ocr-model" - bind:value={config.machineLearning.ocr.modelName} + bind:value={configToEdit.machineLearning.ocr.modelName} options={[ { text: 'PP-OCRv5_server (Chinese, Japanese and English)', value: 'PP-OCRv5_server' }, { text: 'PP-OCRv5_mobile (Chinese, Japanese and English)', value: 'PP-OCRv5_mobile' }, @@ -284,53 +283,48 @@ { text: 'PP-OCRv5_mobile (Russian, Belarusian, Ukrainian and English)', value: 'ESLAV__PP-OCRv5_mobile' }, { text: 'PP-OCRv5_mobile (Thai and English)', value: 'TH__PP-OCRv5_mobile' }, ]} - disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.ocr.enabled} - isEdited={config.machineLearning.ocr.modelName !== savedConfig.machineLearning.ocr.modelName} + disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled} + isEdited={configToEdit.machineLearning.ocr.modelName !== config.machineLearning.ocr.modelName} />
- onReset({ ...options, configKeys: ['machineLearning'] })} - onSave={() => onSave({ machineLearning: config.machineLearning })} - showResetToDefault={!isEqual(savedConfig.machineLearning, defaultConfig.machineLearning)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 4db210d8dc..5ecb9f5419 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -1,35 +1,22 @@
-
+ event.preventDefault()}>
@@ -37,7 +24,7 @@ title={$t('admin.map_enable_description')} subtitle={$t('admin.map_implications')} {disabled} - bind:checked={config.map.enabled} + bind:checked={configToEdit.map.enabled} />
@@ -46,17 +33,17 @@ inputType={SettingInputFieldType.TEXT} label={$t('admin.map_light_style')} description={$t('admin.map_style_description')} - bind:value={config.map.lightStyle} - disabled={disabled || !config.map.enabled} - isEdited={config.map.lightStyle !== savedConfig.map.lightStyle} + bind:value={configToEdit.map.lightStyle} + disabled={disabled || !configToEdit.map.enabled} + isEdited={configToEdit.map.lightStyle !== config.map.lightStyle} />
@@ -82,20 +69,12 @@
- onReset({ ...options, configKeys: ['map', 'reverseGeocoding'] })} - onSave={() => onSave({ map: config.map, reverseGeocoding: config.reverseGeocoding })} - showResetToDefault={!isEqual( - { map: savedConfig.map, reverseGeocoding: savedConfig.reverseGeocoding }, - { map: defaultConfig.map, reverseGeocoding: defaultConfig.reverseGeocoding }, - )} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/MetadataSettings.svelte b/web/src/lib/components/admin-settings/MetadataSettings.svelte index 04e2d010e1..607faef51c 100644 --- a/web/src/lib/components/admin-settings/MetadataSettings.svelte +++ b/web/src/lib/components/admin-settings/MetadataSettings.svelte @@ -1,46 +1,27 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['metadata'] })} - onSave={() => onSave({ metadata: config.metadata })} - showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte b/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte index b713f906c0..327bf34717 100644 --- a/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte +++ b/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte @@ -1,44 +1,25 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['newVersionCheck'] })} - onSave={() => onSave({ newVersionCheck: config.newVersionCheck })} - showResetToDefault={!isEqual(savedConfig.newVersionCheck, defaultConfig.newVersionCheck)} + bind:checked={configToEdit.newVersionCheck.enabled} {disabled} /> +
diff --git a/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte b/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte index 9ba4e4e3b8..688c7cb4f0 100644 --- a/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte +++ b/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte @@ -1,81 +1,63 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['nightlyTasks'] })} - onSave={() => onSave({ nightlyTasks: config.nightlyTasks })} - showResetToDefault={!isEqual(savedConfig.nightlyTasks, defaultConfig.nightlyTasks)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/NotificationSettings.svelte b/web/src/lib/components/admin-settings/NotificationSettings.svelte index 35f13da5a0..0e0af55c4f 100644 --- a/web/src/lib/components/admin-settings/NotificationSettings.svelte +++ b/web/src/lib/components/admin-settings/NotificationSettings.svelte @@ -1,29 +1,22 @@
-
+ event.preventDefault()}>

@@ -87,9 +76,9 @@ required label={$t('host')} description={$t('admin.notification_email_host_description')} - disabled={disabled || !config.notifications.smtp.enabled} - bind:value={config.notifications.smtp.transport.host} - isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host} + disabled={disabled || !configToEdit.notifications.smtp.enabled} + bind:value={configToEdit.notifications.smtp.transport.host} + isEdited={configToEdit.notifications.smtp.transport.host !== config.notifications.smtp.transport.host} />
@@ -143,16 +132,16 @@ required label={$t('admin.notification_email_from_address')} description={$t('admin.notification_email_from_address_description')} - disabled={disabled || !config.notifications.smtp.enabled} - bind:value={config.notifications.smtp.from} - isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from} + disabled={disabled || !configToEdit.notifications.smtp.enabled} + bind:value={configToEdit.notifications.smtp.from} + isEdited={configToEdit.notifications.smtp.from !== config.notifications.smtp.from} />
- + - onReset({ ...options, configKeys: ['notifications', 'templates'] })} - onSave={() => onSave({ notifications: config.notifications, templates: config.templates })} - showResetToDefault={!isEqual(savedConfig, defaultConfig)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/ServerSettings.svelte b/web/src/lib/components/admin-settings/ServerSettings.svelte index d04b351eff..4a04010cf3 100644 --- a/web/src/lib/components/admin-settings/ServerSettings.svelte +++ b/web/src/lib/components/admin-settings/ServerSettings.svelte @@ -1,64 +1,46 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['server'] })} - onSave={() => onSave({ server: config.server })} - showResetToDefault={!isEqual(savedConfig.server, defaultConfig.server)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index a779b57f2d..c445769ae0 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -2,50 +2,34 @@ import { resolve } from '$app/paths'; import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte'; import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte'; - import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { AppRoute, SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; + import { handleSystemConfigSave } from '$lib/services/system-config.service'; + import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { user } from '$lib/stores/user.store'; - import { - getStorageTemplateOptions, - type SystemConfigDto, - type SystemConfigTemplateStorageOptionDto, - } from '@immich/sdk'; + import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; import handlebar from 'handlebars'; - import { isEqual } from 'lodash-es'; import * as luxon from 'luxon'; - import type { Snippet } from 'svelte'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import { createBubbler, preventDefault } from 'svelte/legacy'; import { fade } from 'svelte/transition'; - import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings'; - interface Props { - savedConfig: SystemConfigDto; - defaultConfig: SystemConfigDto; - config: SystemConfigDto; - disabled?: boolean; + type Props = { minified?: boolean; - onReset: SettingsResetEvent; - onSave: SettingsSaveEvent; duration?: number; - children?: Snippet; - } + saveOnClose?: boolean; + }; - let { - savedConfig, - defaultConfig, - config = $bindable(), - disabled = false, - minified = false, - onReset, - onSave, - duration = 500, - children, - }: Props = $props(); + const { minified = false, duration = 500, saveOnClose = false }: Props = $props(); + + const disabled = $featureFlags.configFile; + const config = $derived(systemConfigManager.value); + let configToEdit = $state(systemConfigManager.cloneValue()); const bubble = createBubbler(); let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state(); @@ -53,7 +37,7 @@ const getTemplateOptions = async () => { templateOptions = await getStorageTemplateOptions(); - selectedPreset = savedConfig.storageTemplate.template; + selectedPreset = config.storageTemplate.template; }; const getSupportDateTimeFormat = () => getStorageTemplateOptions(); @@ -101,15 +85,21 @@ }; const handlePresetSelection = () => { - config.storageTemplate.template = selectedPreset; + configToEdit.storageTemplate.template = selectedPreset; }; let parsedTemplate = $derived(() => { try { - return renderTemplate(config.storageTemplate.template); + return renderTemplate(configToEdit.storageTemplate.template); } catch { return 'error'; } }); + + onDestroy(async () => { + if (saveOnClose) { + await handleSystemConfigSave({ storageTemplate: configToEdit.storageTemplate }); + } + });
@@ -145,8 +135,8 @@ {#if !minified} @@ -154,14 +144,14 @@ title={$t('admin.storage_template_hash_verification_enabled')} {disabled} subtitle={$t('admin.storage_template_hash_verification_enabled_description')} - bind:checked={config.storageTemplate.hashVerificationEnabled} + bind:checked={configToEdit.storageTemplate.hashVerificationEnabled} isEdited={!( - config.storageTemplate.hashVerificationEnabled === savedConfig.storageTemplate.hashVerificationEnabled + configToEdit.storageTemplate.hashVerificationEnabled === config.storageTemplate.hashVerificationEnabled )} /> {/if} - {#if config.storageTemplate.enabled} + {#if configToEdit.storageTemplate.enabled}

{$t('variables')}

@@ -220,7 +210,7 @@ + const { CopyToClipboard, Upload, Download } = $derived( + getSystemConfigActions($t, $featureFlags, systemConfigManager.value), + ); + {#snippet buttons()} @@ -256,58 +211,27 @@ - - - {#if !$featureFlags.configFile} - - {/if} + + + {/snippet} - - {#snippet children({ savedConfig, defaultConfig })} -
-
- {#if $featureFlags.configFile} - - {/if} -
- -
- - {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} - - adminSettingElement?.handleSave(config)} - onReset={(options) => adminSettingElement?.handleReset(options)} - disabled={$featureFlags.configFile} - bind:config - {defaultConfig} - {savedConfig} - /> - - {/each} - -
-
- {/snippet} -
+
+
+ {#if $featureFlags.configFile} + + {/if} +
+ +
+ + {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} + + + + {/each} + +
+
diff --git a/web/src/routes/admin/system-settings/+page.ts b/web/src/routes/admin/system-settings/+page.ts index 294096a4be..10dc0cf246 100644 --- a/web/src/routes/admin/system-settings/+page.ts +++ b/web/src/routes/admin/system-settings/+page.ts @@ -1,15 +1,17 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getConfig } from '@immich/sdk'; +import { getConfig, getConfigDefaults } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url, { admin: true }); - const configs = await getConfig(); + const config = await getConfig(); + const defaultConfig = await getConfigDefaults(); const $t = await getFormatter(); return { - configs, + config, + defaultConfig, meta: { title: $t('admin.system_settings'), }, diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 352eaed408..2943dc1e07 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -3,7 +3,7 @@ import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import { AppRoute } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; + import { featureFlags, serverConfig } from '$lib/stores/system-config-manager.svelte'; import { oauth } from '$lib/utils'; import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; import { login, type LoginResponseDto } from '@immich/sdk'; diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index 54c5da716a..ddf5b43fb9 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -1,5 +1,5 @@ import { AppRoute } from '$lib/constants'; -import { serverConfig } from '$lib/stores/server-config.store'; +import { serverConfig } from '$lib/stores/system-config-manager.svelte'; import { getFormatter } from '$lib/utils/i18n'; import { redirect } from '@sveltejs/kit'; diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index d2e9a9f240..9275fb95c1 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -12,7 +12,7 @@ import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; import { OnboardingRole } from '$lib/models/onboarding-role'; - import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store'; + import { retrieveServerConfig, serverConfig, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { user } from '$lib/stores/user.store'; import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk'; import { @@ -152,11 +152,13 @@ ); }; - onMount(async () => { - await retrieveSystemConfig(); - }); - const OnboardingStep = $derived(onboardingSteps[index].component); + + onMount(async () => { + if (userRole === OnboardingRole.SERVER) { + await systemConfigManager.init(); + } + });
diff --git a/web/src/routes/auth/register/+page.svelte b/web/src/routes/auth/register/+page.svelte index 3eb046e80f..affa5f816c 100644 --- a/web/src/routes/auth/register/+page.svelte +++ b/web/src/routes/auth/register/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import { AppRoute } from '$lib/constants'; - import { retrieveServerConfig } from '$lib/stores/server-config.store'; + import { retrieveServerConfig } from '$lib/stores/system-config-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { signUpAdmin } from '@immich/sdk'; import { Alert, Button, Field, Input, PasswordInput, Text } from '@immich/ui'; diff --git a/web/src/routes/auth/register/+page.ts b/web/src/routes/auth/register/+page.ts index 88b56caa47..30969c3167 100644 --- a/web/src/routes/auth/register/+page.ts +++ b/web/src/routes/auth/register/+page.ts @@ -1,5 +1,5 @@ import { AppRoute } from '$lib/constants'; -import { serverConfig } from '$lib/stores/server-config.store'; +import { serverConfig } from '$lib/stores/system-config-manager.svelte'; import { getFormatter } from '$lib/utils/i18n'; import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; From 074fdb2b961eb9e9e8430068a1b3c6deb212ccd8 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:13:09 +0100 Subject: [PATCH 88/93] fix: out of sync pnpm lockfile (#23891) --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cbd915fb3..81ad7fcd3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17278,7 +17278,7 @@ snapshots: bcrypt@6.0.0: dependencies: node-addon-api: 8.5.0 - node-gyp: 11.3.0 + node-gyp: 11.5.0 node-gyp-build: 4.8.4 transitivePeerDependencies: - supports-color From f11bfb95814583458e7244cb772e6a2ea6dcaef3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 14 Nov 2025 11:46:32 -0500 Subject: [PATCH 89/93] fix(server): broken memories (#23896) --- server/src/controllers/memory.controller.spec.ts | 5 +++++ server/src/dtos/memory.dto.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index ac96e54a5b..8629b6c799 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -24,6 +24,11 @@ describe(MemoryController.name, () => { await request(ctx.getHttpServer()).get('/memories'); expect(ctx.authenticate).toHaveBeenCalled(); }); + + it('should not require any parameters', async () => { + await request(ctx.getHttpServer()).get('/memories').query({}); + expect(service.search).toHaveBeenCalled(); + }); }); describe('POST /memories', () => { diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 8a86e66691..8e7320f831 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -5,7 +5,7 @@ import { Memory } from 'src/database'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOrderWithRandom, MemoryType } from 'src/enum'; -import { ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; class MemoryBaseDto { @ValidateBoolean({ optional: true }) @@ -31,6 +31,7 @@ export class MemorySearchDto { @IsInt() @IsPositive() @Type(() => Number) + @Optional() @ApiProperty({ type: 'integer', description: 'Number of memories to return' }) size?: number; From 1200bfad131166a26257ba8b86178ea7457f46c3 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 14 Nov 2025 20:10:44 +0100 Subject: [PATCH 90/93] refactor: server config and feature flags managers (#23894) --- .../admin-settings/AdminSettings.svelte | 78 ----------- .../admin-settings/AuthSettings.svelte | 5 +- .../admin-settings/BackupSettings.svelte | 5 +- .../admin-settings/FFmpegSettings.svelte | 5 +- .../admin-settings/ImageSettings.svelte | 5 +- .../admin-settings/JobSettings.svelte | 5 +- .../admin-settings/LibrarySettings.svelte | 5 +- .../admin-settings/LoggingSettings.svelte | 5 +- .../MachineLearningSettings.svelte | 7 +- .../admin-settings/MapSettings.svelte | 5 +- .../admin-settings/MetadataSettings.svelte | 5 +- .../NewVersionCheckSettings.svelte | 5 +- .../NightlyTasksSettings.svelte | 5 +- .../NotificationSettings.svelte | 5 +- .../admin-settings/ServerSettings.svelte | 5 +- .../StorageTemplateSettings.svelte | 5 +- .../admin-settings/TemplateSettings.svelte | 2 +- .../admin-settings/ThemeSettings.svelte | 5 +- .../admin-settings/TrashSettings.svelte | 5 +- .../admin-settings/UserSettings.svelte | 5 +- .../admin-settings/admin-settings.ts | 4 - .../components/album-page/album-viewer.svelte | 4 +- .../asset-viewer/actions/delete-action.svelte | 4 +- .../asset-viewer/asset-viewer-nav-bar.spec.ts | 4 + .../asset-viewer/asset-viewer-nav-bar.svelte | 4 +- .../asset-viewer/detail-panel.svelte | 4 +- web/src/lib/components/jobs/JobsPanel.svelte | 19 +-- .../onboarding-page/onboarding-hello.svelte | 6 +- .../onboarding-server-privacy.svelte | 2 +- .../gallery-viewer/gallery-viewer.svelte | 4 +- .../shared-components/map/map.svelte | 6 +- .../navigation-bar/navigation-bar.svelte | 6 +- .../search-bar/search-text-section.svelte | 6 +- .../settings/SystemConfigButtonRow.svelte | 2 +- .../side-bar/user-sidebar.svelte | 8 +- .../lib/components/timeline/Timeline.svelte | 1 - .../actions/DeleteAssetsAction.svelte | 9 +- .../actions/TimelineKeyboardActions.svelte | 4 +- .../user-settings-page/oauth-settings.svelte | 4 +- .../user-settings-list.svelte | 4 +- .../managers/feature-flags-manager.svelte.ts | 28 ++++ .../managers/server-config-manager.svelte.ts | 28 ++++ .../managers/system-config-manager.svelte.ts | 55 ++++++++ web/src/lib/modals/PinCodeResetModal.svelte | 4 +- web/src/lib/modals/UserCreateModal.svelte | 8 +- .../lib/modals/UserDeleteConfirmModal.svelte | 4 +- web/src/lib/services/shared-link.service.ts | 5 +- web/src/lib/services/system-config.service.ts | 11 +- web/src/lib/services/user-admin.service.ts | 6 +- .../stores/system-config-manager.svelte.ts | 108 --------------- web/src/lib/utils/license-utils.ts | 5 +- web/src/lib/utils/server.ts | 6 +- .../[[assetId=id]]/+page.svelte | 4 +- .../[[assetId=id]]/+page.svelte | 14 +- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 8 ++ .../[[assetId=id]]/+page.svelte | 4 +- .../[[assetId=id]]/+page.svelte | 16 +-- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 8 ++ .../[[assetId=id]]/+page.svelte | 14 +- web/src/routes/+layout.svelte | 12 +- web/src/routes/+page.ts | 7 +- web/src/routes/admin/+layout.ts | 2 +- .../routes/admin/system-settings/+page.svelte | 7 +- web/src/routes/auth/login/+page.svelte | 123 +++++++++--------- web/src/routes/auth/login/+page.ts | 7 +- web/src/routes/auth/onboarding/+page.svelte | 13 +- web/src/routes/auth/register/+page.svelte | 4 +- web/src/routes/auth/register/+page.ts | 6 +- 68 files changed, 378 insertions(+), 416 deletions(-) delete mode 100644 web/src/lib/components/admin-settings/AdminSettings.svelte delete mode 100644 web/src/lib/components/admin-settings/admin-settings.ts create mode 100644 web/src/lib/managers/feature-flags-manager.svelte.ts create mode 100644 web/src/lib/managers/server-config-manager.svelte.ts create mode 100644 web/src/lib/managers/system-config-manager.svelte.ts delete mode 100644 web/src/lib/stores/system-config-manager.svelte.ts diff --git a/web/src/lib/components/admin-settings/AdminSettings.svelte b/web/src/lib/components/admin-settings/AdminSettings.svelte deleted file mode 100644 index d8fde9e29b..0000000000 --- a/web/src/lib/components/admin-settings/AdminSettings.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -{#if savedConfig && defaultConfig} - {@render children({ savedConfig, defaultConfig })} -{/if} diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index f88ec18ab9..c53060706e 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -6,8 +6,9 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import { SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk'; import { Button, modalManager, Text, toastManager } from '@immich/ui'; @@ -15,7 +16,7 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/BackupSettings.svelte b/web/src/lib/components/admin-settings/BackupSettings.svelte index aa198f88b8..fc374ddd6f 100644 --- a/web/src/lib/components/admin-settings/BackupSettings.svelte +++ b/web/src/lib/components/admin-settings/BackupSettings.svelte @@ -5,11 +5,12 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index ff22960b49..83596069f9 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -7,7 +7,8 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import { SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { AudioCodec, CQMode, @@ -23,7 +24,7 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/ImageSettings.svelte b/web/src/lib/components/admin-settings/ImageSettings.svelte index d63e48d372..afed6b3738 100644 --- a/web/src/lib/components/admin-settings/ImageSettings.svelte +++ b/web/src/lib/components/admin-settings/ImageSettings.svelte @@ -8,10 +8,11 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import { SettingInputFieldType } from '$lib/constants'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/JobSettings.svelte b/web/src/lib/components/admin-settings/JobSettings.svelte index 5fd7b4117f..fb8a11b33b 100644 --- a/web/src/lib/components/admin-settings/JobSettings.svelte +++ b/web/src/lib/components/admin-settings/JobSettings.svelte @@ -2,13 +2,14 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { SettingInputFieldType } from '$lib/constants'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { getJobName } from '$lib/utils'; import { JobName, type SystemConfigJobDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index 83fc3fdbed..a91a5eb97a 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -6,11 +6,12 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import { SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/LoggingSettings.svelte b/web/src/lib/components/admin-settings/LoggingSettings.svelte index 707689d502..6052b8ea9f 100644 --- a/web/src/lib/components/admin-settings/LoggingSettings.svelte +++ b/web/src/lib/components/admin-settings/LoggingSettings.svelte @@ -2,12 +2,13 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { LogLevel } from '@immich/sdk'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte index 773d30f05e..579efef916 100644 --- a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte +++ b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte @@ -6,14 +6,15 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import { SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { Button, IconButton } from '@immich/ui'; import { mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import { isEqual } from 'lodash-es'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); @@ -167,7 +168,7 @@ min={0.001} max={0.1} description={$t('admin.machine_learning_max_detection_distance_description')} - disabled={disabled || !$featureFlags.duplicateDetection} + disabled={disabled || !featureFlagsManager.value.duplicateDetection} isEdited={configToEdit.machineLearning.duplicateDetection.maxDistance !== config.machineLearning.duplicateDetection.maxDistance} /> diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 5ecb9f5419..692a5cfcf5 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -5,11 +5,12 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import { SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/MetadataSettings.svelte b/web/src/lib/components/admin-settings/MetadataSettings.svelte index 607faef51c..0db36e0e82 100644 --- a/web/src/lib/components/admin-settings/MetadataSettings.svelte +++ b/web/src/lib/components/admin-settings/MetadataSettings.svelte @@ -1,11 +1,12 @@ diff --git a/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte b/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte index 327bf34717..d8a79d6236 100644 --- a/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte +++ b/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte @@ -1,11 +1,12 @@ diff --git a/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte b/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte index 688c7cb4f0..9647f0c7c3 100644 --- a/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte +++ b/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte @@ -3,11 +3,12 @@ import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { SettingInputFieldType } from '$lib/constants'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/NotificationSettings.svelte b/web/src/lib/components/admin-settings/NotificationSettings.svelte index 0e0af55c4f..e97af356df 100644 --- a/web/src/lib/components/admin-settings/NotificationSettings.svelte +++ b/web/src/lib/components/admin-settings/NotificationSettings.svelte @@ -5,8 +5,9 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import { SettingInputFieldType } from '$lib/constants'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { handleSystemConfigSave } from '$lib/services/system-config.service'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { user } from '$lib/stores/user.store'; import { handleError } from '$lib/utils/handle-error'; import { sendTestEmailAdmin } from '@immich/sdk'; @@ -14,7 +15,7 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/ServerSettings.svelte b/web/src/lib/components/admin-settings/ServerSettings.svelte index 4a04010cf3..936c4f406e 100644 --- a/web/src/lib/components/admin-settings/ServerSettings.svelte +++ b/web/src/lib/components/admin-settings/ServerSettings.svelte @@ -3,11 +3,12 @@ import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { SettingInputFieldType } from '$lib/constants'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index c445769ae0..3dbf697de1 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -7,8 +7,9 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { AppRoute, SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { handleSystemConfigSave } from '$lib/services/system-config.service'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { user } from '$lib/stores/user.store'; import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; @@ -27,7 +28,7 @@ const { minified = false, duration = 500, saveOnClose = false }: Props = $props(); - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/TemplateSettings.svelte b/web/src/lib/components/admin-settings/TemplateSettings.svelte index 6e64055879..7ef1bcde0d 100644 --- a/web/src/lib/components/admin-settings/TemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/TemplateSettings.svelte @@ -2,8 +2,8 @@ import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte'; - import { systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk'; import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui'; diff --git a/web/src/lib/components/admin-settings/ThemeSettings.svelte b/web/src/lib/components/admin-settings/ThemeSettings.svelte index 34c4246885..f2e9b51f11 100644 --- a/web/src/lib/components/admin-settings/ThemeSettings.svelte +++ b/web/src/lib/components/admin-settings/ThemeSettings.svelte @@ -1,11 +1,12 @@ diff --git a/web/src/lib/components/admin-settings/TrashSettings.svelte b/web/src/lib/components/admin-settings/TrashSettings.svelte index 5d811af841..9e71d0abc1 100644 --- a/web/src/lib/components/admin-settings/TrashSettings.svelte +++ b/web/src/lib/components/admin-settings/TrashSettings.svelte @@ -3,11 +3,12 @@ import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { SettingInputFieldType } from '$lib/constants'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/UserSettings.svelte b/web/src/lib/components/admin-settings/UserSettings.svelte index 3d91cff56a..23ea052b52 100644 --- a/web/src/lib/components/admin-settings/UserSettings.svelte +++ b/web/src/lib/components/admin-settings/UserSettings.svelte @@ -4,10 +4,11 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { SettingInputFieldType } from '$lib/constants'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; - const disabled = $featureFlags.configFile; + const disabled = $derived(featureFlagsManager.value.configFile); const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); diff --git a/web/src/lib/components/admin-settings/admin-settings.ts b/web/src/lib/components/admin-settings/admin-settings.ts deleted file mode 100644 index 6b0e70dee1..0000000000 --- a/web/src/lib/components/admin-settings/admin-settings.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { ResetOptions } from '$lib/utils/dipatch'; -import type { SystemConfigDto } from '@immich/sdk'; - -export type SettingsResetOptions = ResetOptions & { configKeys: Array }; diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 7e882042cb..a7c9243e64 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -6,12 +6,12 @@ import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { handleDownloadAlbum } from '$lib/services/album.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; - import { featureFlags } from '$lib/stores/system-config-manager.svelte'; import { handlePromiseError } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; @@ -126,7 +126,7 @@ icon={mdiDownload} /> {/if} - {#if sharedLink.showMetadata && $featureFlags.loaded && $featureFlags.map} + {#if sharedLink.showMetadata && featureFlagsManager.value.map} {/if} diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.svelte b/web/src/lib/components/asset-viewer/actions/delete-action.svelte index 84be7f3a79..74d40c7cee 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte @@ -3,8 +3,8 @@ import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte'; import { AssetAction } from '$lib/constants'; import Portal from '$lib/elements/Portal.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { showDeleteModal } from '$lib/stores/preferences.store'; - import { featureFlags } from '$lib/stores/system-config-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { deleteAssets, type AssetResponseDto } from '@immich/sdk'; @@ -24,7 +24,7 @@ let showConfirmModal = $state(false); const trashOrDelete = async (force = false) => { - if (force || !$featureFlags.trash) { + if (force || !featureFlagsManager.value.trash) { if ($showDeleteModal) { showConfirmModal = true; return; diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts index f6a46143bc..55231c11ae 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts @@ -32,6 +32,10 @@ describe('AssetViewerNavBar component', () => { 'ResizeObserver', vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })), ); + vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { smartSearch: true } } as any }; + }); }); afterEach(() => { diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6c66b47286..7daade6379 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -24,8 +24,8 @@ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AppRoute } from '$lib/constants'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; - import { featureFlags } from '$lib/stores/system-config-manager.svelte'; import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; import { getAssetJobName, getSharedLink } from '$lib/utils'; @@ -108,7 +108,7 @@ let isOwner = $derived($user && asset.ownerId === $user?.id); let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); let isLocked = $derived(asset.visibility === AssetVisibility.Locked); - let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch); + let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); // $: showEditorButton = // isOwner && diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 51c3098356..a9c447e498 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -7,11 +7,11 @@ import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte'; import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; - import { featureFlags } from '$lib/stores/system-config-manager.svelte'; import { preferences, user } from '$lib/stores/user.store'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { delay, getDimensions } from '$lib/utils/asset-utils'; @@ -438,7 +438,7 @@
-{#if latlng && $featureFlags.loaded && $featureFlags.map} +{#if latlng && featureFlagsManager.value.map}
{#await import('$lib/components/shared-components/map/map.svelte')} {#await delay(timeToLoadTheMap) then} diff --git a/web/src/lib/components/jobs/JobsPanel.svelte b/web/src/lib/components/jobs/JobsPanel.svelte index f9a4e5a735..e204c76648 100644 --- a/web/src/lib/components/jobs/JobsPanel.svelte +++ b/web/src/lib/components/jobs/JobsPanel.svelte @@ -1,5 +1,5 @@
diff --git a/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte index c7e5e6a71e..c299d0bc35 100644 --- a/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte @@ -1,7 +1,7 @@ -{#if $featureFlags.loaded && $featureFlags.map} +{#if featureFlagsManager.value.map}
{#await import('$lib/components/shared-components/map/map.svelte')} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts index add9882bcd..e797c4d8b6 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,3 +1,7 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; +import { handlePromiseError } from '$lib/utils'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; @@ -8,6 +12,10 @@ export const load = (async ({ params, url }) => { const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); + if (!featureFlagsManager.value.map) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } + return { asset, meta: { diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index b1cbe51392..97964344ef 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -21,11 +21,11 @@ import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { lang, locale } from '$lib/stores/preferences.store'; - import { featureFlags } from '$lib/stores/system-config-manager.svelte'; import { preferences } from '$lib/stores/user.store'; import { handlePromiseError } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; @@ -67,7 +67,7 @@ type SearchTerms = MetadataSearchDto & Pick; let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); - let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch); + let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); $effect(() => { diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 87eb4eb70c..99aad49285 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,5 +1,4 @@ -{#if $featureFlags.loaded && $featureFlags.trash} +{#if featureFlagsManager.value.trash} {#snippet buttons()} @@ -105,7 +99,9 @@

- {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })} + {$t('trashed_items_will_be_permanently_deleted_after', { + values: { days: serverConfigManager.value.trashDays }, + })}

{#snippet empty()} diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts index 79c41892c7..eddf9aa6af 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,3 +1,7 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; +import { handlePromiseError } from '$lib/utils'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; @@ -8,6 +12,10 @@ export const load = (async ({ params, url }) => { const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); + if (!featureFlagsManager.value.trash) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } + return { asset, meta: { diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 8bb99895af..c6943c6491 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,11 +5,11 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import { AppRoute } from '$lib/constants'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { locale } from '$lib/stores/preferences.store'; - import { featureFlags } from '$lib/stores/system-config-manager.svelte'; import { stackAssets } from '$lib/utils/asset-utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { handleError } from '$lib/utils/handle-error'; @@ -92,7 +92,7 @@ return; } - const message = $featureFlags.trash + const message = featureFlagsManager.value.trash ? $t('assets_moved_to_trash_count', { values: { count: trashedCount } }) : $t('permanently_deleted_assets_count', { values: { count: trashedCount } }); toastManager.success(message); @@ -101,7 +101,7 @@ const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => { return withConfirmation( async () => { - await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } }); + await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !featureFlagsManager.value.trash } }); await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); @@ -109,8 +109,8 @@ deletedNotification(trashIds.length); await correctDuplicatesIndexAndGo(duplicatesIndex); }, - trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined, - trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined, + trashIds.length > 0 && !featureFlagsManager.value.trash ? $t('delete_duplicates_confirmation') : undefined, + trashIds.length > 0 && !featureFlagsManager.value.trash ? $t('permanently_delete') : undefined, ); }; @@ -129,7 +129,7 @@ ); let prompt, confirmText; - if ($featureFlags.trash) { + if (featureFlagsManager.value.trash) { prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } }); confirmText = $t('confirm'); } else { @@ -139,7 +139,7 @@ return withConfirmation( async () => { - await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !$featureFlags.trash } }); + await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !featureFlagsManager.value.trash } }); await updateAssets({ assetBulkUpdateDto: { ids: [...idsToDelete, ...idsToKeep.filter((id): id is string => !!id)], diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index a52f51210f..8b63017c19 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -8,8 +8,8 @@ import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte'; import UploadPanel from '$lib/components/shared-components/upload-panel.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; + import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte'; - import { serverConfig } from '$lib/stores/system-config-manager.svelte'; import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, @@ -120,7 +120,10 @@ {#if page.data.meta.imageUrl} {/if} @@ -131,7 +134,10 @@ {#if page.data.meta.imageUrl} {/if} {/if} diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index f617c2a03e..bd6d1a62da 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -1,10 +1,8 @@ import { AppRoute } from '$lib/constants'; -import { serverConfig } from '$lib/stores/system-config-manager.svelte'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { getFormatter } from '$lib/utils/i18n'; import { init } from '$lib/utils/server'; - import { redirect } from '@sveltejs/kit'; -import { get } from 'svelte/store'; import { loadUser } from '../lib/utils/auth'; import type { PageLoad } from './$types'; @@ -19,8 +17,7 @@ export const load = (async ({ fetch }) => { redirect(302, AppRoute.PHOTOS); } - const { isInitialized } = get(serverConfig); - if (isInitialized) { + if (serverConfigManager.value.isInitialized) { // Redirect to login page if there exists an admin account (i.e. server is initialized) redirect(302, AppRoute.AUTH_LOGIN); } diff --git a/web/src/routes/admin/+layout.ts b/web/src/routes/admin/+layout.ts index 885e57b05e..778e5f182f 100644 --- a/web/src/routes/admin/+layout.ts +++ b/web/src/routes/admin/+layout.ts @@ -1,4 +1,4 @@ -import { systemConfigManager } from '$lib/stores/system-config-manager.svelte'; +import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import type { LayoutLoad } from './$types'; export const load = (async () => { diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 179350ef6b..3f3d9a4f83 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -23,8 +23,9 @@ import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import { QueryParameter } from '$lib/constants'; import SearchBar from '$lib/elements/SearchBar.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { getSystemConfigActions } from '$lib/services/system-config.service'; - import { featureFlags, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { Alert, HStack } from '@immich/ui'; import { mdiAccountOutline, @@ -201,7 +202,7 @@ ); const { CopyToClipboard, Upload, Download } = $derived( - getSystemConfigActions($t, $featureFlags, systemConfigManager.value), + getSystemConfigActions($t, featureFlagsManager.value, systemConfigManager.value), ); @@ -219,7 +220,7 @@
- {#if $featureFlags.configFile} + {#if featureFlagsManager.value.configFile} {/if}
diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 2943dc1e07..88a557f845 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -3,7 +3,8 @@ import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import { AppRoute } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { featureFlags, serverConfig } from '$lib/stores/system-config-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { oauth } from '$lib/utils'; import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; import { login, type LoginResponseDto } from '@immich/sdk'; @@ -25,6 +26,8 @@ let loading = $state(false); let oauthLoading = $state(true); + const serverConfig = $derived(serverConfigManager.value); + const onSuccess = async (user: LoginResponseDto) => { await goto(data.continueUrl, { invalidateAll: true }); eventManager.emit('AuthLogin', user); @@ -34,7 +37,7 @@ const onOnboarding = () => goto(AppRoute.AUTH_ONBOARDING); onMount(async () => { - if (!$featureFlags.oauth) { + if (!featureFlagsManager.value.oauth) { oauthLoading = false; return; } @@ -60,7 +63,7 @@ try { if ( - ($featureFlags.oauthAutoLaunch && !oauth.isAutoLaunchDisabled(globalThis.location)) || + (featureFlagsManager.value.oauthAutoLaunch && !oauth.isAutoLaunchDisabled(globalThis.location)) || oauth.isAutoLaunchEnabled(globalThis.location) ) { await goto(`${AppRoute.AUTH_LOGIN}?autoLaunch=0`, { replaceState: true }); @@ -80,7 +83,7 @@ loading = true; const user = await login({ loginCredentialDto: { email, password } }); - if (user.isAdmin && !$serverConfig.isOnboarded) { + if (user.isAdmin && !serverConfig.isOnboarded) { await onOnboarding(); return; } @@ -123,64 +126,62 @@ }; -{#if $featureFlags.loaded} - - - {#if $serverConfig.loginPageMessage} - - - {@html $serverConfig.loginPageMessage} - - {/if} + + + {#if serverConfig.loginPageMessage} + + + {@html serverConfig.loginPageMessage} + + {/if} - {#if !oauthLoading && $featureFlags.passwordLogin} -
- {#if errorMessage} - - {/if} - - - - - - - - - - - - {/if} - - {#if $featureFlags.oauth} - {#if $featureFlags.passwordLogin} -
-
- - {$t('or')} - -
+ {#if !oauthLoading && featureFlagsManager.value.passwordLogin} +
+ {#if errorMessage} + {/if} - {#if oauthError} - - {/if} - - {/if} - {#if !$featureFlags.passwordLogin && !$featureFlags.oauth} - + + + + + + + + + + + {/if} + + {#if featureFlagsManager.value.oauth} + {#if featureFlagsManager.value.passwordLogin} +
+
+ + {$t('or')} + +
{/if} -
-
-{/if} + {#if oauthError} + + {/if} + + {/if} + + {#if !featureFlagsManager.value.passwordLogin && !featureFlagsManager.value.oauth} + + {/if} +
+
diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index ddf5b43fb9..5577ab1a7e 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -1,16 +1,13 @@ import { AppRoute } from '$lib/constants'; -import { serverConfig } from '$lib/stores/system-config-manager.svelte'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { getFormatter } from '$lib/utils/i18n'; - import { redirect } from '@sveltejs/kit'; -import { get } from 'svelte/store'; import type { PageLoad } from './$types'; export const load = (async ({ parent, url }) => { await parent(); - const { isInitialized } = get(serverConfig); - if (!isInitialized) { + if (!serverConfigManager.value.isInitialized) { // Admin not registered redirect(302, AppRoute.AUTH_REGISTER); } diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 9275fb95c1..44cd97637a 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -11,8 +11,9 @@ import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; + import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { OnboardingRole } from '$lib/models/onboarding-role'; - import { retrieveServerConfig, serverConfig, systemConfigManager } from '$lib/stores/system-config-manager.svelte'; import { user } from '$lib/stores/user.store'; import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk'; import { @@ -95,7 +96,9 @@ ]); let index = $state(0); - let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER); + let userRole = $derived( + $user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER, + ); let onboardingStepCount = $derived(onboardingSteps.filter((step) => shouldRunStep(step.role, userRole)).length); let onboardingProgress = $derived( @@ -105,7 +108,9 @@ const shouldRunStep = (stepRole: OnboardingRole, userRole: OnboardingRole) => { return ( stepRole === OnboardingRole.USER || - (stepRole === OnboardingRole.SERVER && userRole === OnboardingRole.SERVER && !$serverConfig.isOnboarded) + (stepRole === OnboardingRole.SERVER && + userRole === OnboardingRole.SERVER && + !serverConfigManager.value.isOnboarded) ); }; @@ -127,7 +132,7 @@ if (nextStepIndex == -1) { if ($user.isAdmin) { await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); - await retrieveServerConfig(); + await serverConfigManager.loadServerConfig(); } await setUserOnboarding({ diff --git a/web/src/routes/auth/register/+page.svelte b/web/src/routes/auth/register/+page.svelte index affa5f816c..e78f782841 100644 --- a/web/src/routes/auth/register/+page.svelte +++ b/web/src/routes/auth/register/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import { AppRoute } from '$lib/constants'; - import { retrieveServerConfig } from '$lib/stores/system-config-manager.svelte'; + import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { signUpAdmin } from '@immich/sdk'; import { Alert, Button, Field, Input, PasswordInput, Text } from '@immich/ui'; @@ -37,7 +37,7 @@ try { await signUpAdmin({ signUpDto: { email, password, name } }); - await retrieveServerConfig(); + await serverConfigManager.loadServerConfig(); await goto(AppRoute.AUTH_LOGIN); } catch (error) { handleError(error, $t('errors.unable_to_create_admin_account')); diff --git a/web/src/routes/auth/register/+page.ts b/web/src/routes/auth/register/+page.ts index 30969c3167..344a37738c 100644 --- a/web/src/routes/auth/register/+page.ts +++ b/web/src/routes/auth/register/+page.ts @@ -1,14 +1,12 @@ import { AppRoute } from '$lib/constants'; -import { serverConfig } from '$lib/stores/system-config-manager.svelte'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { getFormatter } from '$lib/utils/i18n'; import { redirect } from '@sveltejs/kit'; -import { get } from 'svelte/store'; import type { PageLoad } from './$types'; export const load = (async ({ parent }) => { await parent(); - const { isInitialized } = get(serverConfig); - if (isInitialized) { + if (serverConfigManager.value.isInitialized) { // Admin has been registered, redirect to login redirect(302, AppRoute.AUTH_LOGIN); } From d784d431d058cd3227eb6b41a5d2f0e22ad5fa03 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 14 Nov 2025 14:42:00 -0500 Subject: [PATCH 91/93] refactor: job vs queue naming (#23902) --- e2e/src/api/specs/jobs.e2e-spec.ts | 86 ++--- e2e/src/api/specs/user-admin.e2e-spec.ts | 4 +- e2e/src/utils.ts | 20 +- mobile/openapi/README.md | 16 +- mobile/openapi/lib/api.dart | 12 +- mobile/openapi/lib/api/jobs_api.dart | 30 +- mobile/openapi/lib/api_client.dart | 24 +- mobile/openapi/lib/api_helper.dart | 12 +- mobile/openapi/lib/model/job_command.dart | 94 ----- mobile/openapi/lib/model/job_name.dart | 127 ------- mobile/openapi/lib/model/queue_command.dart | 94 +++++ ...ommand_dto.dart => queue_command_dto.dart} | 42 +-- mobile/openapi/lib/model/queue_name.dart | 127 +++++++ ...tatus_dto.dart => queue_response_dto.dart} | 42 +-- ...nts_dto.dart => queue_statistics_dto.dart} | 38 +- ...onse_dto.dart => queues_response_dto.dart} | 102 ++--- open-api/immich-openapi-specs.json | 348 +++++++++--------- open-api/typescript-sdk/src/fetch-client.ts | 64 ++-- server/src/app.module.ts | 12 +- server/src/controllers/job.controller.ts | 19 +- server/src/dtos/job.dto.ts | 96 +---- server/src/dtos/queue.dto.ts | 94 +++++ server/src/enum.ts | 2 +- server/src/services/api.service.ts | 2 - server/src/services/index.ts | 2 + server/src/services/job.service.spec.ts | 207 +---------- server/src/services/job.service.ts | 252 +------------ server/src/services/queue.service.spec.ts | 223 +++++++++++ server/src/services/queue.service.ts | 250 +++++++++++++ .../admin-settings/JobSettings.svelte | 40 +- web/src/lib/components/jobs/JobTile.svelte | 38 +- web/src/lib/components/jobs/JobsPanel.svelte | 86 +++-- web/src/lib/utils.ts | 42 +-- web/src/routes/admin/jobs-status/+page.svelte | 22 +- web/src/routes/admin/jobs-status/+page.ts | 4 +- .../admin/library-management/+page.svelte | 8 +- 36 files changed, 1356 insertions(+), 1325 deletions(-) delete mode 100644 mobile/openapi/lib/model/job_command.dart delete mode 100644 mobile/openapi/lib/model/job_name.dart create mode 100644 mobile/openapi/lib/model/queue_command.dart rename mobile/openapi/lib/model/{job_command_dto.dart => queue_command_dto.dart} (66%) create mode 100644 mobile/openapi/lib/model/queue_name.dart rename mobile/openapi/lib/model/{job_status_dto.dart => queue_response_dto.dart} (62%) rename mobile/openapi/lib/model/{job_counts_dto.dart => queue_statistics_dto.dart} (70%) rename mobile/openapi/lib/model/{all_job_status_response_dto.dart => queues_response_dto.dart} (57%) create mode 100644 server/src/dtos/queue.dto.ts create mode 100644 server/src/services/queue.service.spec.ts create mode 100644 server/src/services/queue.service.ts diff --git a/e2e/src/api/specs/jobs.e2e-spec.ts b/e2e/src/api/specs/jobs.e2e-spec.ts index a9afd8475f..be7984404b 100644 --- a/e2e/src/api/specs/jobs.e2e-spec.ts +++ b/e2e/src/api/specs/jobs.e2e-spec.ts @@ -1,4 +1,4 @@ -import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk'; +import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk'; import { cpSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; @@ -17,28 +17,28 @@ describe('/jobs', () => { describe('PUT /jobs', () => { afterEach(async () => { - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.FaceDetection, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.SmartSearch, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, { + command: QueueCommand.Resume, force: false, }); @@ -59,8 +59,8 @@ describe('/jobs', () => { it('should queue metadata extraction for missing assets', async () => { const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`; - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Pause, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Pause, force: false, }); @@ -77,20 +77,20 @@ describe('/jobs', () => { expect(asset.exifInfo?.make).toBeNull(); } - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Empty, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Empty, force: false, }); await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Start, force: false, }); @@ -124,8 +124,8 @@ describe('/jobs', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path); - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Start, force: false, }); @@ -144,8 +144,8 @@ describe('/jobs', () => { it('should queue thumbnail extraction for assets missing thumbs', async () => { const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`; - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Pause, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Pause, force: false, }); @@ -153,32 +153,32 @@ describe('/jobs', () => { assetData: { bytes: await readFile(path), filename: basename(path) }, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetBefore = await utils.getAssetInfo(admin.accessToken, id); expect(assetBefore.thumbhash).toBeNull(); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Empty, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Empty, force: false, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Start, force: false, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetAfter = await utils.getAssetInfo(admin.accessToken, id); expect(assetAfter.thumbhash).not.toBeNull(); @@ -193,26 +193,26 @@ describe('/jobs', () => { assetData: { bytes: await readFile(path), filename: basename(path) }, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetBefore = await utils.getAssetInfo(admin.accessToken, id); cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Resume, force: false, }); // This runs the missing thumbnail job - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Start, force: false, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetAfter = await utils.getAssetInfo(admin.accessToken, id); diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 2d6e08b5fb..793c508a36 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,6 +1,6 @@ import { - JobName, LoginResponseDto, + QueueName, createStack, deleteUserAdmin, getMyUser, @@ -328,7 +328,7 @@ describe('/admin/users', () => { { headers: asBearerAuth(user.accessToken) }, ); - await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask); + await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask); const { status, body } = await request(app) .delete(`/admin/users/${user.userId}`) diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index b33d6cb190..8f34bbe40a 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,5 +1,4 @@ import { - AllJobStatusResponseDto, AssetMediaCreateDto, AssetMediaResponseDto, AssetResponseDto, @@ -7,11 +6,12 @@ import { CheckExistingAssetsDto, CreateAlbumDto, CreateLibraryDto, - JobCommandDto, - JobName, MetadataSearchDto, Permission, PersonCreateDto, + QueueCommandDto, + QueueName, + QueuesResponseDto, SharedLinkCreateDto, UpdateLibraryDto, UserAdminCreateDto, @@ -27,14 +27,14 @@ import { createStack, createUserAdmin, deleteAssets, - getAllJobsStatus, getAssetInfo, getConfig, getConfigDefaults, + getQueuesLegacy, login, + runQueueCommandLegacy, scanLibrary, searchAssets, - sendJobCommand, setBaseUrl, signUpAdmin, tagAssets, @@ -477,8 +477,8 @@ export const utils = { tagAssets: (accessToken: string, tagId: string, assetIds: string[]) => tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }), - jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) => - sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }), + queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) => + runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }), setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') => await context.addCookies([ @@ -524,13 +524,13 @@ export const utils = { await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); }, - isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => { - const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) }); + isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => { + const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) }); const jobCounts = queues[queue].jobCounts; return !jobCounts.active && !jobCounts.waiting; }, - waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => { + waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ff04a91def..5e93c571bd 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -149,8 +149,8 @@ Class | Method | HTTP request | Description *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | Retrieve faces for asset *FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | Re-assign a face to another person *JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | Create a manual job -*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | Retrieve queue counts and status -*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | Run jobs +*JobsApi* | [**getQueuesLegacy**](doc//JobsApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status +*JobsApi* | [**runQueueCommandLegacy**](doc//JobsApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | Create a library *LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} | Delete a library *LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | Retrieve libraries @@ -318,7 +318,6 @@ Class | Method | HTTP request | Description - [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md) - [AlbumsResponse](doc//AlbumsResponse.md) - [AlbumsUpdate](doc//AlbumsUpdate.md) - - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) - [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md) - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md) - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md) @@ -387,13 +386,8 @@ Class | Method | HTTP request | Description - [FoldersResponse](doc//FoldersResponse.md) - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) - - [JobCommand](doc//JobCommand.md) - - [JobCommandDto](doc//JobCommandDto.md) - - [JobCountsDto](doc//JobCountsDto.md) - [JobCreateDto](doc//JobCreateDto.md) - - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - - [JobStatusDto](doc//JobStatusDto.md) - [LibraryResponseDto](doc//LibraryResponseDto.md) - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md) - [LicenseKeyDto](doc//LicenseKeyDto.md) @@ -452,7 +446,13 @@ Class | Method | HTTP request | Description - [PlacesResponseDto](doc//PlacesResponseDto.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) + - [QueueCommand](doc//QueueCommand.md) + - [QueueCommandDto](doc//QueueCommandDto.md) + - [QueueName](doc//QueueName.md) + - [QueueResponseDto](doc//QueueResponseDto.md) + - [QueueStatisticsDto](doc//QueueStatisticsDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [QueuesResponseDto](doc//QueuesResponseDto.md) - [RandomSearchDto](doc//RandomSearchDto.md) - [RatingsResponse](doc//RatingsResponse.md) - [RatingsUpdate](doc//RatingsUpdate.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d0ac141bae..c64295837c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -82,7 +82,6 @@ part 'model/albums_add_assets_dto.dart'; part 'model/albums_add_assets_response_dto.dart'; part 'model/albums_response.dart'; part 'model/albums_update.dart'; -part 'model/all_job_status_response_dto.dart'; part 'model/asset_bulk_delete_dto.dart'; part 'model/asset_bulk_update_dto.dart'; part 'model/asset_bulk_upload_check_dto.dart'; @@ -151,13 +150,8 @@ part 'model/facial_recognition_config.dart'; part 'model/folders_response.dart'; part 'model/folders_update.dart'; part 'model/image_format.dart'; -part 'model/job_command.dart'; -part 'model/job_command_dto.dart'; -part 'model/job_counts_dto.dart'; part 'model/job_create_dto.dart'; -part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; -part 'model/job_status_dto.dart'; part 'model/library_response_dto.dart'; part 'model/library_stats_response_dto.dart'; part 'model/license_key_dto.dart'; @@ -216,7 +210,13 @@ part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; +part 'model/queue_command.dart'; +part 'model/queue_command_dto.dart'; +part 'model/queue_name.dart'; +part 'model/queue_response_dto.dart'; +part 'model/queue_statistics_dto.dart'; part 'model/queue_status_dto.dart'; +part 'model/queues_response_dto.dart'; part 'model/random_search_dto.dart'; part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index e783f93c7c..906dce6d37 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -69,7 +69,7 @@ class JobsApi { /// Retrieve the counts of the current queue, as well as the current status. /// /// Note: This method returns the HTTP [Response]. - Future getAllJobsStatusWithHttpInfo() async { + Future getQueuesLegacyWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/jobs'; @@ -97,8 +97,8 @@ class JobsApi { /// Retrieve queue counts and status /// /// Retrieve the counts of the current queue, as well as the current status. - Future getAllJobsStatus() async { - final response = await getAllJobsStatusWithHttpInfo(); + Future getQueuesLegacy() async { + final response = await getQueuesLegacyWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -106,7 +106,7 @@ class JobsApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AllJobStatusResponseDto',) as AllJobStatusResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseDto',) as QueuesResponseDto; } return null; @@ -120,16 +120,16 @@ class JobsApi { /// /// Parameters: /// - /// * [JobName] id (required): + /// * [QueueName] name (required): /// - /// * [JobCommandDto] jobCommandDto (required): - Future sendJobCommandWithHttpInfo(JobName id, JobCommandDto jobCommandDto,) async { + /// * [QueueCommandDto] queueCommandDto (required): + Future runQueueCommandLegacyWithHttpInfo(QueueName name, QueueCommandDto queueCommandDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/jobs/{id}' - .replaceAll('{id}', id.toString()); + final apiPath = r'/jobs/{name}' + .replaceAll('{name}', name.toString()); // ignore: prefer_final_locals - Object? postBody = jobCommandDto; + Object? postBody = queueCommandDto; final queryParams = []; final headerParams = {}; @@ -155,11 +155,11 @@ class JobsApi { /// /// Parameters: /// - /// * [JobName] id (required): + /// * [QueueName] name (required): /// - /// * [JobCommandDto] jobCommandDto (required): - Future sendJobCommand(JobName id, JobCommandDto jobCommandDto,) async { - final response = await sendJobCommandWithHttpInfo(id, jobCommandDto,); + /// * [QueueCommandDto] queueCommandDto (required): + Future runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async { + final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -167,7 +167,7 @@ class JobsApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'JobStatusDto',) as JobStatusDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto; } return null; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index cf6784fb83..373a4b9d8b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -220,8 +220,6 @@ class ApiClient { return AlbumsResponse.fromJson(value); case 'AlbumsUpdate': return AlbumsUpdate.fromJson(value); - case 'AllJobStatusResponseDto': - return AllJobStatusResponseDto.fromJson(value); case 'AssetBulkDeleteDto': return AssetBulkDeleteDto.fromJson(value); case 'AssetBulkUpdateDto': @@ -358,20 +356,10 @@ class ApiClient { return FoldersUpdate.fromJson(value); case 'ImageFormat': return ImageFormatTypeTransformer().decode(value); - case 'JobCommand': - return JobCommandTypeTransformer().decode(value); - case 'JobCommandDto': - return JobCommandDto.fromJson(value); - case 'JobCountsDto': - return JobCountsDto.fromJson(value); case 'JobCreateDto': return JobCreateDto.fromJson(value); - case 'JobName': - return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': return JobSettingsDto.fromJson(value); - case 'JobStatusDto': - return JobStatusDto.fromJson(value); case 'LibraryResponseDto': return LibraryResponseDto.fromJson(value); case 'LibraryStatsResponseDto': @@ -488,8 +476,20 @@ class ApiClient { return PurchaseResponse.fromJson(value); case 'PurchaseUpdate': return PurchaseUpdate.fromJson(value); + case 'QueueCommand': + return QueueCommandTypeTransformer().decode(value); + case 'QueueCommandDto': + return QueueCommandDto.fromJson(value); + case 'QueueName': + return QueueNameTypeTransformer().decode(value); + case 'QueueResponseDto': + return QueueResponseDto.fromJson(value); + case 'QueueStatisticsDto': + return QueueStatisticsDto.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'QueuesResponseDto': + return QueuesResponseDto.fromJson(value); case 'RandomSearchDto': return RandomSearchDto.fromJson(value); case 'RatingsResponse': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1d197a8f91..5c21009a0b 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -94,12 +94,6 @@ String parameterToString(dynamic value) { if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } - if (value is JobCommand) { - return JobCommandTypeTransformer().encode(value).toString(); - } - if (value is JobName) { - return JobNameTypeTransformer().encode(value).toString(); - } if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } @@ -127,6 +121,12 @@ String parameterToString(dynamic value) { if (value is Permission) { return PermissionTypeTransformer().encode(value).toString(); } + if (value is QueueCommand) { + return QueueCommandTypeTransformer().encode(value).toString(); + } + if (value is QueueName) { + return QueueNameTypeTransformer().encode(value).toString(); + } if (value is ReactionLevel) { return ReactionLevelTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/job_command.dart b/mobile/openapi/lib/model/job_command.dart deleted file mode 100644 index 46ca7db68f..0000000000 --- a/mobile/openapi/lib/model/job_command.dart +++ /dev/null @@ -1,94 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class JobCommand { - /// Instantiate a new enum with the provided [value]. - const JobCommand._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const start = JobCommand._(r'start'); - static const pause = JobCommand._(r'pause'); - static const resume = JobCommand._(r'resume'); - static const empty = JobCommand._(r'empty'); - static const clearFailed = JobCommand._(r'clear-failed'); - - /// List of all possible values in this [enum][JobCommand]. - static const values = [ - start, - pause, - resume, - empty, - clearFailed, - ]; - - static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = JobCommand.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [JobCommand] to String, -/// and [decode] dynamic data back to [JobCommand]. -class JobCommandTypeTransformer { - factory JobCommandTypeTransformer() => _instance ??= const JobCommandTypeTransformer._(); - - const JobCommandTypeTransformer._(); - - String encode(JobCommand data) => data.value; - - /// Decodes a [dynamic value][data] to a JobCommand. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - JobCommand? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'start': return JobCommand.start; - case r'pause': return JobCommand.pause; - case r'resume': return JobCommand.resume; - case r'empty': return JobCommand.empty; - case r'clear-failed': return JobCommand.clearFailed; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [JobCommandTypeTransformer] instance. - static JobCommandTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart deleted file mode 100644 index bbb9111105..0000000000 --- a/mobile/openapi/lib/model/job_name.dart +++ /dev/null @@ -1,127 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class JobName { - /// Instantiate a new enum with the provided [value]. - const JobName._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const thumbnailGeneration = JobName._(r'thumbnailGeneration'); - static const metadataExtraction = JobName._(r'metadataExtraction'); - static const videoConversion = JobName._(r'videoConversion'); - static const faceDetection = JobName._(r'faceDetection'); - static const facialRecognition = JobName._(r'facialRecognition'); - static const smartSearch = JobName._(r'smartSearch'); - static const duplicateDetection = JobName._(r'duplicateDetection'); - static const backgroundTask = JobName._(r'backgroundTask'); - static const storageTemplateMigration = JobName._(r'storageTemplateMigration'); - static const migration = JobName._(r'migration'); - static const search = JobName._(r'search'); - static const sidecar = JobName._(r'sidecar'); - static const library_ = JobName._(r'library'); - static const notifications = JobName._(r'notifications'); - static const backupDatabase = JobName._(r'backupDatabase'); - static const ocr = JobName._(r'ocr'); - - /// List of all possible values in this [enum][JobName]. - static const values = [ - thumbnailGeneration, - metadataExtraction, - videoConversion, - faceDetection, - facialRecognition, - smartSearch, - duplicateDetection, - backgroundTask, - storageTemplateMigration, - migration, - search, - sidecar, - library_, - notifications, - backupDatabase, - ocr, - ]; - - static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = JobName.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [JobName] to String, -/// and [decode] dynamic data back to [JobName]. -class JobNameTypeTransformer { - factory JobNameTypeTransformer() => _instance ??= const JobNameTypeTransformer._(); - - const JobNameTypeTransformer._(); - - String encode(JobName data) => data.value; - - /// Decodes a [dynamic value][data] to a JobName. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - JobName? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'thumbnailGeneration': return JobName.thumbnailGeneration; - case r'metadataExtraction': return JobName.metadataExtraction; - case r'videoConversion': return JobName.videoConversion; - case r'faceDetection': return JobName.faceDetection; - case r'facialRecognition': return JobName.facialRecognition; - case r'smartSearch': return JobName.smartSearch; - case r'duplicateDetection': return JobName.duplicateDetection; - case r'backgroundTask': return JobName.backgroundTask; - case r'storageTemplateMigration': return JobName.storageTemplateMigration; - case r'migration': return JobName.migration; - case r'search': return JobName.search; - case r'sidecar': return JobName.sidecar; - case r'library': return JobName.library_; - case r'notifications': return JobName.notifications; - case r'backupDatabase': return JobName.backupDatabase; - case r'ocr': return JobName.ocr; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [JobNameTypeTransformer] instance. - static JobNameTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/queue_command.dart b/mobile/openapi/lib/model/queue_command.dart new file mode 100644 index 0000000000..f03ec6eccd --- /dev/null +++ b/mobile/openapi/lib/model/queue_command.dart @@ -0,0 +1,94 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class QueueCommand { + /// Instantiate a new enum with the provided [value]. + const QueueCommand._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const start = QueueCommand._(r'start'); + static const pause = QueueCommand._(r'pause'); + static const resume = QueueCommand._(r'resume'); + static const empty = QueueCommand._(r'empty'); + static const clearFailed = QueueCommand._(r'clear-failed'); + + /// List of all possible values in this [enum][QueueCommand]. + static const values = [ + start, + pause, + resume, + empty, + clearFailed, + ]; + + static QueueCommand? fromJson(dynamic value) => QueueCommandTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = QueueCommand.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [QueueCommand] to String, +/// and [decode] dynamic data back to [QueueCommand]. +class QueueCommandTypeTransformer { + factory QueueCommandTypeTransformer() => _instance ??= const QueueCommandTypeTransformer._(); + + const QueueCommandTypeTransformer._(); + + String encode(QueueCommand data) => data.value; + + /// Decodes a [dynamic value][data] to a QueueCommand. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + QueueCommand? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'start': return QueueCommand.start; + case r'pause': return QueueCommand.pause; + case r'resume': return QueueCommand.resume; + case r'empty': return QueueCommand.empty; + case r'clear-failed': return QueueCommand.clearFailed; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [QueueCommandTypeTransformer] instance. + static QueueCommandTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/queue_command_dto.dart similarity index 66% rename from mobile/openapi/lib/model/job_command_dto.dart rename to mobile/openapi/lib/model/queue_command_dto.dart index 32274037f6..ded848c12f 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/queue_command_dto.dart @@ -10,14 +10,14 @@ part of openapi.api; -class JobCommandDto { - /// Returns a new [JobCommandDto] instance. - JobCommandDto({ +class QueueCommandDto { + /// Returns a new [QueueCommandDto] instance. + QueueCommandDto({ required this.command, this.force, }); - JobCommand command; + QueueCommand command; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -28,7 +28,7 @@ class JobCommandDto { bool? force; @override - bool operator ==(Object other) => identical(this, other) || other is JobCommandDto && + bool operator ==(Object other) => identical(this, other) || other is QueueCommandDto && other.command == command && other.force == force; @@ -39,7 +39,7 @@ class JobCommandDto { (force == null ? 0 : force!.hashCode); @override - String toString() => 'JobCommandDto[command=$command, force=$force]'; + String toString() => 'QueueCommandDto[command=$command, force=$force]'; Map toJson() { final json = {}; @@ -52,27 +52,27 @@ class JobCommandDto { return json; } - /// Returns a new [JobCommandDto] instance and imports its values from + /// Returns a new [QueueCommandDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static JobCommandDto? fromJson(dynamic value) { - upgradeDto(value, "JobCommandDto"); + static QueueCommandDto? fromJson(dynamic value) { + upgradeDto(value, "QueueCommandDto"); if (value is Map) { final json = value.cast(); - return JobCommandDto( - command: JobCommand.fromJson(json[r'command'])!, + return QueueCommandDto( + command: QueueCommand.fromJson(json[r'command'])!, force: mapValueOfType(json, r'force'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = JobCommandDto.fromJson(row); + final value = QueueCommandDto.fromJson(row); if (value != null) { result.add(value); } @@ -81,12 +81,12 @@ class JobCommandDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = JobCommandDto.fromJson(entry.value); + final value = QueueCommandDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -95,14 +95,14 @@ class JobCommandDto { return map; } - // maps a json object with a list of JobCommandDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueueCommandDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = JobCommandDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueueCommandDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/queue_name.dart b/mobile/openapi/lib/model/queue_name.dart new file mode 100644 index 0000000000..7b8214e202 --- /dev/null +++ b/mobile/openapi/lib/model/queue_name.dart @@ -0,0 +1,127 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class QueueName { + /// Instantiate a new enum with the provided [value]. + const QueueName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const thumbnailGeneration = QueueName._(r'thumbnailGeneration'); + static const metadataExtraction = QueueName._(r'metadataExtraction'); + static const videoConversion = QueueName._(r'videoConversion'); + static const faceDetection = QueueName._(r'faceDetection'); + static const facialRecognition = QueueName._(r'facialRecognition'); + static const smartSearch = QueueName._(r'smartSearch'); + static const duplicateDetection = QueueName._(r'duplicateDetection'); + static const backgroundTask = QueueName._(r'backgroundTask'); + static const storageTemplateMigration = QueueName._(r'storageTemplateMigration'); + static const migration = QueueName._(r'migration'); + static const search = QueueName._(r'search'); + static const sidecar = QueueName._(r'sidecar'); + static const library_ = QueueName._(r'library'); + static const notifications = QueueName._(r'notifications'); + static const backupDatabase = QueueName._(r'backupDatabase'); + static const ocr = QueueName._(r'ocr'); + + /// List of all possible values in this [enum][QueueName]. + static const values = [ + thumbnailGeneration, + metadataExtraction, + videoConversion, + faceDetection, + facialRecognition, + smartSearch, + duplicateDetection, + backgroundTask, + storageTemplateMigration, + migration, + search, + sidecar, + library_, + notifications, + backupDatabase, + ocr, + ]; + + static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = QueueName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [QueueName] to String, +/// and [decode] dynamic data back to [QueueName]. +class QueueNameTypeTransformer { + factory QueueNameTypeTransformer() => _instance ??= const QueueNameTypeTransformer._(); + + const QueueNameTypeTransformer._(); + + String encode(QueueName data) => data.value; + + /// Decodes a [dynamic value][data] to a QueueName. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + QueueName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'thumbnailGeneration': return QueueName.thumbnailGeneration; + case r'metadataExtraction': return QueueName.metadataExtraction; + case r'videoConversion': return QueueName.videoConversion; + case r'faceDetection': return QueueName.faceDetection; + case r'facialRecognition': return QueueName.facialRecognition; + case r'smartSearch': return QueueName.smartSearch; + case r'duplicateDetection': return QueueName.duplicateDetection; + case r'backgroundTask': return QueueName.backgroundTask; + case r'storageTemplateMigration': return QueueName.storageTemplateMigration; + case r'migration': return QueueName.migration; + case r'search': return QueueName.search; + case r'sidecar': return QueueName.sidecar; + case r'library': return QueueName.library_; + case r'notifications': return QueueName.notifications; + case r'backupDatabase': return QueueName.backupDatabase; + case r'ocr': return QueueName.ocr; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [QueueNameTypeTransformer] instance. + static QueueNameTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/queue_response_dto.dart similarity index 62% rename from mobile/openapi/lib/model/job_status_dto.dart rename to mobile/openapi/lib/model/queue_response_dto.dart index 18fab8dfb3..b20449f721 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/queue_response_dto.dart @@ -10,19 +10,19 @@ part of openapi.api; -class JobStatusDto { - /// Returns a new [JobStatusDto] instance. - JobStatusDto({ +class QueueResponseDto { + /// Returns a new [QueueResponseDto] instance. + QueueResponseDto({ required this.jobCounts, required this.queueStatus, }); - JobCountsDto jobCounts; + QueueStatisticsDto jobCounts; QueueStatusDto queueStatus; @override - bool operator ==(Object other) => identical(this, other) || other is JobStatusDto && + bool operator ==(Object other) => identical(this, other) || other is QueueResponseDto && other.jobCounts == jobCounts && other.queueStatus == queueStatus; @@ -33,7 +33,7 @@ class JobStatusDto { (queueStatus.hashCode); @override - String toString() => 'JobStatusDto[jobCounts=$jobCounts, queueStatus=$queueStatus]'; + String toString() => 'QueueResponseDto[jobCounts=$jobCounts, queueStatus=$queueStatus]'; Map toJson() { final json = {}; @@ -42,27 +42,27 @@ class JobStatusDto { return json; } - /// Returns a new [JobStatusDto] instance and imports its values from + /// Returns a new [QueueResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static JobStatusDto? fromJson(dynamic value) { - upgradeDto(value, "JobStatusDto"); + static QueueResponseDto? fromJson(dynamic value) { + upgradeDto(value, "QueueResponseDto"); if (value is Map) { final json = value.cast(); - return JobStatusDto( - jobCounts: JobCountsDto.fromJson(json[r'jobCounts'])!, + return QueueResponseDto( + jobCounts: QueueStatisticsDto.fromJson(json[r'jobCounts'])!, queueStatus: QueueStatusDto.fromJson(json[r'queueStatus'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = JobStatusDto.fromJson(row); + final value = QueueResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -71,12 +71,12 @@ class JobStatusDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = JobStatusDto.fromJson(entry.value); + final value = QueueResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -85,14 +85,14 @@ class JobStatusDto { return map; } - // maps a json object with a list of JobStatusDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueueResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = JobStatusDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueueResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/queue_statistics_dto.dart similarity index 70% rename from mobile/openapi/lib/model/job_counts_dto.dart rename to mobile/openapi/lib/model/queue_statistics_dto.dart index afc90d1084..c27c4a5892 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/queue_statistics_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class JobCountsDto { - /// Returns a new [JobCountsDto] instance. - JobCountsDto({ +class QueueStatisticsDto { + /// Returns a new [QueueStatisticsDto] instance. + QueueStatisticsDto({ required this.active, required this.completed, required this.delayed, @@ -34,7 +34,7 @@ class JobCountsDto { int waiting; @override - bool operator ==(Object other) => identical(this, other) || other is JobCountsDto && + bool operator ==(Object other) => identical(this, other) || other is QueueStatisticsDto && other.active == active && other.completed == completed && other.delayed == delayed && @@ -53,7 +53,7 @@ class JobCountsDto { (waiting.hashCode); @override - String toString() => 'JobCountsDto[active=$active, completed=$completed, delayed=$delayed, failed=$failed, paused=$paused, waiting=$waiting]'; + String toString() => 'QueueStatisticsDto[active=$active, completed=$completed, delayed=$delayed, failed=$failed, paused=$paused, waiting=$waiting]'; Map toJson() { final json = {}; @@ -66,15 +66,15 @@ class JobCountsDto { return json; } - /// Returns a new [JobCountsDto] instance and imports its values from + /// Returns a new [QueueStatisticsDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static JobCountsDto? fromJson(dynamic value) { - upgradeDto(value, "JobCountsDto"); + static QueueStatisticsDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatisticsDto"); if (value is Map) { final json = value.cast(); - return JobCountsDto( + return QueueStatisticsDto( active: mapValueOfType(json, r'active')!, completed: mapValueOfType(json, r'completed')!, delayed: mapValueOfType(json, r'delayed')!, @@ -86,11 +86,11 @@ class JobCountsDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = JobCountsDto.fromJson(row); + final value = QueueStatisticsDto.fromJson(row); if (value != null) { result.add(value); } @@ -99,12 +99,12 @@ class JobCountsDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = JobCountsDto.fromJson(entry.value); + final value = QueueStatisticsDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -113,14 +113,14 @@ class JobCountsDto { return map; } - // maps a json object with a list of JobCountsDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueueStatisticsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = JobCountsDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueueStatisticsDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/queues_response_dto.dart similarity index 57% rename from mobile/openapi/lib/model/all_job_status_response_dto.dart rename to mobile/openapi/lib/model/queues_response_dto.dart index 291bec4394..b20f8c3c09 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/queues_response_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class AllJobStatusResponseDto { - /// Returns a new [AllJobStatusResponseDto] instance. - AllJobStatusResponseDto({ +class QueuesResponseDto { + /// Returns a new [QueuesResponseDto] instance. + QueuesResponseDto({ required this.backgroundTask, required this.backupDatabase, required this.duplicateDetection, @@ -31,40 +31,40 @@ class AllJobStatusResponseDto { required this.videoConversion, }); - JobStatusDto backgroundTask; + QueueResponseDto backgroundTask; - JobStatusDto backupDatabase; + QueueResponseDto backupDatabase; - JobStatusDto duplicateDetection; + QueueResponseDto duplicateDetection; - JobStatusDto faceDetection; + QueueResponseDto faceDetection; - JobStatusDto facialRecognition; + QueueResponseDto facialRecognition; - JobStatusDto library_; + QueueResponseDto library_; - JobStatusDto metadataExtraction; + QueueResponseDto metadataExtraction; - JobStatusDto migration; + QueueResponseDto migration; - JobStatusDto notifications; + QueueResponseDto notifications; - JobStatusDto ocr; + QueueResponseDto ocr; - JobStatusDto search; + QueueResponseDto search; - JobStatusDto sidecar; + QueueResponseDto sidecar; - JobStatusDto smartSearch; + QueueResponseDto smartSearch; - JobStatusDto storageTemplateMigration; + QueueResponseDto storageTemplateMigration; - JobStatusDto thumbnailGeneration; + QueueResponseDto thumbnailGeneration; - JobStatusDto videoConversion; + QueueResponseDto videoConversion; @override - bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && + bool operator ==(Object other) => identical(this, other) || other is QueuesResponseDto && other.backgroundTask == backgroundTask && other.backupDatabase == backupDatabase && other.duplicateDetection == duplicateDetection && @@ -103,7 +103,7 @@ class AllJobStatusResponseDto { (videoConversion.hashCode); @override - String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'QueuesResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; Map toJson() { final json = {}; @@ -126,41 +126,41 @@ class AllJobStatusResponseDto { return json; } - /// Returns a new [AllJobStatusResponseDto] instance and imports its values from + /// Returns a new [QueuesResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AllJobStatusResponseDto? fromJson(dynamic value) { - upgradeDto(value, "AllJobStatusResponseDto"); + static QueuesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "QueuesResponseDto"); if (value is Map) { final json = value.cast(); - return AllJobStatusResponseDto( - backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!, - backupDatabase: JobStatusDto.fromJson(json[r'backupDatabase'])!, - duplicateDetection: JobStatusDto.fromJson(json[r'duplicateDetection'])!, - faceDetection: JobStatusDto.fromJson(json[r'faceDetection'])!, - facialRecognition: JobStatusDto.fromJson(json[r'facialRecognition'])!, - library_: JobStatusDto.fromJson(json[r'library'])!, - metadataExtraction: JobStatusDto.fromJson(json[r'metadataExtraction'])!, - migration: JobStatusDto.fromJson(json[r'migration'])!, - notifications: JobStatusDto.fromJson(json[r'notifications'])!, - ocr: JobStatusDto.fromJson(json[r'ocr'])!, - search: JobStatusDto.fromJson(json[r'search'])!, - sidecar: JobStatusDto.fromJson(json[r'sidecar'])!, - smartSearch: JobStatusDto.fromJson(json[r'smartSearch'])!, - storageTemplateMigration: JobStatusDto.fromJson(json[r'storageTemplateMigration'])!, - thumbnailGeneration: JobStatusDto.fromJson(json[r'thumbnailGeneration'])!, - videoConversion: JobStatusDto.fromJson(json[r'videoConversion'])!, + return QueuesResponseDto( + backgroundTask: QueueResponseDto.fromJson(json[r'backgroundTask'])!, + backupDatabase: QueueResponseDto.fromJson(json[r'backupDatabase'])!, + duplicateDetection: QueueResponseDto.fromJson(json[r'duplicateDetection'])!, + faceDetection: QueueResponseDto.fromJson(json[r'faceDetection'])!, + facialRecognition: QueueResponseDto.fromJson(json[r'facialRecognition'])!, + library_: QueueResponseDto.fromJson(json[r'library'])!, + metadataExtraction: QueueResponseDto.fromJson(json[r'metadataExtraction'])!, + migration: QueueResponseDto.fromJson(json[r'migration'])!, + notifications: QueueResponseDto.fromJson(json[r'notifications'])!, + ocr: QueueResponseDto.fromJson(json[r'ocr'])!, + search: QueueResponseDto.fromJson(json[r'search'])!, + sidecar: QueueResponseDto.fromJson(json[r'sidecar'])!, + smartSearch: QueueResponseDto.fromJson(json[r'smartSearch'])!, + storageTemplateMigration: QueueResponseDto.fromJson(json[r'storageTemplateMigration'])!, + thumbnailGeneration: QueueResponseDto.fromJson(json[r'thumbnailGeneration'])!, + videoConversion: QueueResponseDto.fromJson(json[r'videoConversion'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AllJobStatusResponseDto.fromJson(row); + final value = QueuesResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -169,12 +169,12 @@ class AllJobStatusResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AllJobStatusResponseDto.fromJson(entry.value); + final value = QueuesResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -183,14 +183,14 @@ class AllJobStatusResponseDto { return map; } - // maps a json object with a list of AllJobStatusResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueuesResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = AllJobStatusResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueuesResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e4fc5ddd96..7ae48eaf8b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4836,14 +4836,14 @@ "/jobs": { "get": { "description": "Retrieve the counts of the current queue, as well as the current status.", - "operationId": "getAllJobsStatus", + "operationId": "getQueuesLegacy", "parameters": [], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AllJobStatusResponseDto" + "$ref": "#/components/schemas/QueuesResponseDto" } } }, @@ -4936,17 +4936,17 @@ "x-immich-state": "Stable" } }, - "/jobs/{id}": { + "/jobs/{name}": { "put": { "description": "Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.", - "operationId": "sendJobCommand", + "operationId": "runQueueCommandLegacy", "parameters": [ { - "name": "id", + "name": "name", "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/JobName" + "$ref": "#/components/schemas/QueueName" } } ], @@ -4954,7 +4954,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobCommandDto" + "$ref": "#/components/schemas/QueueCommandDto" } } }, @@ -4965,7 +4965,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobStatusDto" + "$ref": "#/components/schemas/QueueResponseDto" } } }, @@ -14084,77 +14084,6 @@ }, "type": "object" }, - "AllJobStatusResponseDto": { - "properties": { - "backgroundTask": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "backupDatabase": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "duplicateDetection": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "faceDetection": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "facialRecognition": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "library": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "metadataExtraction": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "migration": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "notifications": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "ocr": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "search": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "sidecar": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "smartSearch": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "storageTemplateMigration": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "thumbnailGeneration": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "videoConversion": { - "$ref": "#/components/schemas/JobStatusDto" - } - }, - "required": [ - "backgroundTask", - "backupDatabase", - "duplicateDetection", - "faceDetection", - "facialRecognition", - "library", - "metadataExtraction", - "migration", - "notifications", - "ocr", - "search", - "sidecar", - "smartSearch", - "storageTemplateMigration", - "thumbnailGeneration", - "videoConversion" - ], - "type": "object" - }, "AssetBulkDeleteDto": { "properties": { "force": { @@ -15866,65 +15795,6 @@ ], "type": "string" }, - "JobCommand": { - "enum": [ - "start", - "pause", - "resume", - "empty", - "clear-failed" - ], - "type": "string" - }, - "JobCommandDto": { - "properties": { - "command": { - "allOf": [ - { - "$ref": "#/components/schemas/JobCommand" - } - ] - }, - "force": { - "type": "boolean" - } - }, - "required": [ - "command" - ], - "type": "object" - }, - "JobCountsDto": { - "properties": { - "active": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "delayed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "paused": { - "type": "integer" - }, - "waiting": { - "type": "integer" - } - }, - "required": [ - "active", - "completed", - "delayed", - "failed", - "paused", - "waiting" - ], - "type": "object" - }, "JobCreateDto": { "properties": { "name": { @@ -15940,27 +15810,6 @@ ], "type": "object" }, - "JobName": { - "enum": [ - "thumbnailGeneration", - "metadataExtraction", - "videoConversion", - "faceDetection", - "facialRecognition", - "smartSearch", - "duplicateDetection", - "backgroundTask", - "storageTemplateMigration", - "migration", - "search", - "sidecar", - "library", - "notifications", - "backupDatabase", - "ocr" - ], - "type": "string" - }, "JobSettingsDto": { "properties": { "concurrency": { @@ -15973,21 +15822,6 @@ ], "type": "object" }, - "JobStatusDto": { - "properties": { - "jobCounts": { - "$ref": "#/components/schemas/JobCountsDto" - }, - "queueStatus": { - "$ref": "#/components/schemas/QueueStatusDto" - } - }, - "required": [ - "jobCounts", - "queueStatus" - ], - "type": "object" - }, "LibraryResponseDto": { "properties": { "assetCount": { @@ -17559,6 +17393,101 @@ }, "type": "object" }, + "QueueCommand": { + "enum": [ + "start", + "pause", + "resume", + "empty", + "clear-failed" + ], + "type": "string" + }, + "QueueCommandDto": { + "properties": { + "command": { + "allOf": [ + { + "$ref": "#/components/schemas/QueueCommand" + } + ] + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "QueueName": { + "enum": [ + "thumbnailGeneration", + "metadataExtraction", + "videoConversion", + "faceDetection", + "facialRecognition", + "smartSearch", + "duplicateDetection", + "backgroundTask", + "storageTemplateMigration", + "migration", + "search", + "sidecar", + "library", + "notifications", + "backupDatabase", + "ocr" + ], + "type": "string" + }, + "QueueResponseDto": { + "properties": { + "jobCounts": { + "$ref": "#/components/schemas/QueueStatisticsDto" + }, + "queueStatus": { + "$ref": "#/components/schemas/QueueStatusDto" + } + }, + "required": [ + "jobCounts", + "queueStatus" + ], + "type": "object" + }, + "QueueStatisticsDto": { + "properties": { + "active": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "delayed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "paused": { + "type": "integer" + }, + "waiting": { + "type": "integer" + } + }, + "required": [ + "active", + "completed", + "delayed", + "failed", + "paused", + "waiting" + ], + "type": "object" + }, "QueueStatusDto": { "properties": { "isActive": { @@ -17574,6 +17503,77 @@ ], "type": "object" }, + "QueuesResponseDto": { + "properties": { + "backgroundTask": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "backupDatabase": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "duplicateDetection": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "faceDetection": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "facialRecognition": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "library": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "metadataExtraction": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "migration": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "notifications": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "ocr": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "search": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "sidecar": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "smartSearch": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "storageTemplateMigration": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "thumbnailGeneration": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "videoConversion": { + "$ref": "#/components/schemas/QueueResponseDto" + } + }, + "required": [ + "backgroundTask", + "backupDatabase", + "duplicateDetection", + "faceDetection", + "facialRecognition", + "library", + "metadataExtraction", + "migration", + "notifications", + "ocr", + "search", + "sidecar", + "smartSearch", + "storageTemplateMigration", + "thumbnailGeneration", + "videoConversion" + ], + "type": "object" + }, "RandomSearchDto": { "properties": { "albumIds": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9aec8b6f87..00a6eea954 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -699,7 +699,7 @@ export type AssetFaceDeleteDto = { export type FaceDto = { id: string; }; -export type JobCountsDto = { +export type QueueStatisticsDto = { active: number; completed: number; delayed: number; @@ -711,33 +711,33 @@ export type QueueStatusDto = { isActive: boolean; isPaused: boolean; }; -export type JobStatusDto = { - jobCounts: JobCountsDto; +export type QueueResponseDto = { + jobCounts: QueueStatisticsDto; queueStatus: QueueStatusDto; }; -export type AllJobStatusResponseDto = { - backgroundTask: JobStatusDto; - backupDatabase: JobStatusDto; - duplicateDetection: JobStatusDto; - faceDetection: JobStatusDto; - facialRecognition: JobStatusDto; - library: JobStatusDto; - metadataExtraction: JobStatusDto; - migration: JobStatusDto; - notifications: JobStatusDto; - ocr: JobStatusDto; - search: JobStatusDto; - sidecar: JobStatusDto; - smartSearch: JobStatusDto; - storageTemplateMigration: JobStatusDto; - thumbnailGeneration: JobStatusDto; - videoConversion: JobStatusDto; +export type QueuesResponseDto = { + backgroundTask: QueueResponseDto; + backupDatabase: QueueResponseDto; + duplicateDetection: QueueResponseDto; + faceDetection: QueueResponseDto; + facialRecognition: QueueResponseDto; + library: QueueResponseDto; + metadataExtraction: QueueResponseDto; + migration: QueueResponseDto; + notifications: QueueResponseDto; + ocr: QueueResponseDto; + search: QueueResponseDto; + sidecar: QueueResponseDto; + smartSearch: QueueResponseDto; + storageTemplateMigration: QueueResponseDto; + thumbnailGeneration: QueueResponseDto; + videoConversion: QueueResponseDto; }; export type JobCreateDto = { name: ManualJobName; }; -export type JobCommandDto = { - command: JobCommand; +export type QueueCommandDto = { + command: QueueCommand; force?: boolean; }; export type LibraryResponseDto = { @@ -2805,10 +2805,10 @@ export function reassignFacesById({ id, faceDto }: { /** * Retrieve queue counts and status */ -export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { +export function getQueuesLegacy(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AllJobStatusResponseDto; + data: QueuesResponseDto; }>("/jobs", { ...opts })); @@ -2828,17 +2828,17 @@ export function createJob({ jobCreateDto }: { /** * Run jobs */ -export function sendJobCommand({ id, jobCommandDto }: { - id: JobName; - jobCommandDto: JobCommandDto; +export function runQueueCommandLegacy({ name, queueCommandDto }: { + name: QueueName; + queueCommandDto: QueueCommandDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: JobStatusDto; - }>(`/jobs/${encodeURIComponent(id)}`, oazapfts.json({ + data: QueueResponseDto; + }>(`/jobs/${encodeURIComponent(name)}`, oazapfts.json({ ...opts, method: "PUT", - body: jobCommandDto + body: queueCommandDto }))); } /** @@ -5067,7 +5067,7 @@ export enum ManualJobName { MemoryCreate = "memory-create", BackupDatabase = "backup-database" } -export enum JobName { +export enum QueueName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", VideoConversion = "videoConversion", @@ -5085,7 +5085,7 @@ export enum JobName { BackupDatabase = "backupDatabase", Ocr = "ocr" } -export enum JobCommand { +export enum QueueCommand { Start = "start", Pause = "pause", Resume = "resume", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 8079441329..f80a47bb77 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -23,7 +23,7 @@ import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { services } from 'src/services'; import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; -import { JobService } from 'src/services/job.service'; +import { QueueService } from 'src/services/queue.service'; import { getKyselyConfig } from 'src/utils/database'; const common = [...repositories, ...services, GlobalExceptionFilter]; @@ -52,11 +52,11 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { constructor( @Inject(IWorker) private worker: ImmichWorker, logger: LoggingRepository, - private eventRepository: EventRepository, - private websocketRepository: WebsocketRepository, - private jobService: JobService, - private telemetryRepository: TelemetryRepository, private authService: AuthService, + private eventRepository: EventRepository, + private queueService: QueueService, + private telemetryRepository: TelemetryRepository, + private websocketRepository: WebsocketRepository, ) { logger.setAppName(this.worker); } @@ -64,7 +64,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { this.telemetryRepository.setup({ repositories }); - this.jobService.setServices(services); + this.queueService.setServices(services); this.websocketRepository.setAuthFn(async (client) => this.authService.authenticate({ diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 34c6bdc27f..977f1e0f1e 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,15 +1,20 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; +import { JobCreateDto } from 'src/dtos/job.dto'; +import { QueueCommandDto, QueueNameParamDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; +import { QueueService } from 'src/services/queue.service'; @ApiTags(ApiTag.Jobs) @Controller('jobs') export class JobController { - constructor(private service: JobService) {} + constructor( + private service: JobService, + private queueService: QueueService, + ) {} @Get() @Authenticated({ permission: Permission.JobRead, admin: true }) @@ -18,8 +23,8 @@ export class JobController { description: 'Retrieve the counts of the current queue, as well as the current status.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - getAllJobsStatus(): Promise { - return this.service.getAllJobsStatus(); + getQueuesLegacy(): Promise { + return this.queueService.getAll(); } @Post() @@ -35,7 +40,7 @@ export class JobController { return this.service.create(dto); } - @Put(':id') + @Put(':name') @Authenticated({ permission: Permission.JobCreate, admin: true }) @Endpoint({ summary: 'Run jobs', @@ -43,7 +48,7 @@ export class JobController { 'Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { - return this.service.handleCommand(id, dto); + runQueueCommandLegacy(@Param() { name }: QueueNameParamDto, @Body() dto: QueueCommandDto): Promise { + return this.queueService.runCommand(name, dto); } } diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index 5daaeacdd3..794af6e5e0 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,99 +1,7 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { JobCommand, ManualJobName, QueueName } from 'src/enum'; -import { ValidateBoolean, ValidateEnum } from 'src/validation'; - -export class JobIdParamDto { - @ValidateEnum({ enum: QueueName, name: 'JobName' }) - id!: QueueName; -} - -export class JobCommandDto { - @ValidateEnum({ enum: JobCommand, name: 'JobCommand' }) - command!: JobCommand; - - @ValidateBoolean({ optional: true }) - force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit -} +import { ManualJobName } from 'src/enum'; +import { ValidateEnum } from 'src/validation'; export class JobCreateDto { @ValidateEnum({ enum: ManualJobName, name: 'ManualJobName' }) name!: ManualJobName; } - -export class JobCountsDto { - @ApiProperty({ type: 'integer' }) - active!: number; - @ApiProperty({ type: 'integer' }) - completed!: number; - @ApiProperty({ type: 'integer' }) - failed!: number; - @ApiProperty({ type: 'integer' }) - delayed!: number; - @ApiProperty({ type: 'integer' }) - waiting!: number; - @ApiProperty({ type: 'integer' }) - paused!: number; -} - -export class QueueStatusDto { - isActive!: boolean; - isPaused!: boolean; -} - -export class JobStatusDto { - @ApiProperty({ type: JobCountsDto }) - jobCounts!: JobCountsDto; - - @ApiProperty({ type: QueueStatusDto }) - queueStatus!: QueueStatusDto; -} - -export class AllJobStatusResponseDto implements Record { - @ApiProperty({ type: JobStatusDto }) - [QueueName.ThumbnailGeneration]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.MetadataExtraction]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.VideoConversion]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.SmartSearch]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.StorageTemplateMigration]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Migration]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.BackgroundTask]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Search]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.DuplicateDetection]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.FaceDetection]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.FacialRecognition]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Sidecar]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Library]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Notification]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.BackupDatabase]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Ocr]!: JobStatusDto; -} diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts new file mode 100644 index 0000000000..1492e014d9 --- /dev/null +++ b/server/src/dtos/queue.dto.ts @@ -0,0 +1,94 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { QueueCommand, QueueName } from 'src/enum'; +import { ValidateBoolean, ValidateEnum } from 'src/validation'; + +export class QueueNameParamDto { + @ValidateEnum({ enum: QueueName, name: 'QueueName' }) + name!: QueueName; +} + +export class QueueCommandDto { + @ValidateEnum({ enum: QueueCommand, name: 'QueueCommand' }) + command!: QueueCommand; + + @ValidateBoolean({ optional: true }) + force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit +} + +export class QueueStatisticsDto { + @ApiProperty({ type: 'integer' }) + active!: number; + @ApiProperty({ type: 'integer' }) + completed!: number; + @ApiProperty({ type: 'integer' }) + failed!: number; + @ApiProperty({ type: 'integer' }) + delayed!: number; + @ApiProperty({ type: 'integer' }) + waiting!: number; + @ApiProperty({ type: 'integer' }) + paused!: number; +} + +export class QueueStatusDto { + isActive!: boolean; + isPaused!: boolean; +} + +export class QueueResponseDto { + @ApiProperty({ type: QueueStatisticsDto }) + jobCounts!: QueueStatisticsDto; + + @ApiProperty({ type: QueueStatusDto }) + queueStatus!: QueueStatusDto; +} + +export class QueuesResponseDto implements Record { + @ApiProperty({ type: QueueResponseDto }) + [QueueName.ThumbnailGeneration]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.MetadataExtraction]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.VideoConversion]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.SmartSearch]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.StorageTemplateMigration]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Migration]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.BackgroundTask]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Search]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.DuplicateDetection]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.FaceDetection]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.FacialRecognition]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Sidecar]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Library]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Notification]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.BackupDatabase]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Ocr]!: QueueResponseDto; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index c706c1da7c..f3814863b1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -603,7 +603,7 @@ export enum JobName { Ocr = 'Ocr', } -export enum JobCommand { +export enum QueueCommand { Start = 'start', Pause = 'pause', Resume = 'resume', diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index ee9b0e622d..0ec2a65f92 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -7,7 +7,6 @@ import { ONE_HOUR } from 'src/constants'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AuthService } from 'src/services/auth.service'; -import { JobService } from 'src/services/job.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { VersionService } from 'src/services/version.service'; import { OpenGraphTags } from 'src/utils/misc'; @@ -40,7 +39,6 @@ const render = (index: string, meta: OpenGraphTags) => { export class ApiService { constructor( private authService: AuthService, - private jobService: JobService, private sharedLinkService: SharedLinkService, private versionService: VersionService, private configRepository: ConfigRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 9a8b0fb2bf..8862a5b37e 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -23,6 +23,7 @@ import { NotificationService } from 'src/services/notification.service'; import { OcrService } from 'src/services/ocr.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; +import { QueueService } from 'src/services/queue.service'; import { SearchService } from 'src/services/search.service'; import { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; @@ -69,6 +70,7 @@ export const services = [ OcrService, PartnerService, PersonService, + QueueService, SearchService, ServerService, SessionService, diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 7a300ae7ae..c23b4f05df 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,6 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; -import { defaults, SystemConfig } from 'src/config'; -import { ImmichWorker, JobCommand, JobName, JobStatus, QueueName } from 'src/enum'; +import { ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { JobService } from 'src/services/job.service'; import { JobItem } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -20,209 +18,6 @@ describe(JobService.name, () => { expect(sut).toBeDefined(); }); - describe('onConfigUpdate', () => { - it('should update concurrency', () => { - sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - - expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.StorageTemplateMigration, 1); - }); - }); - - describe('handleNightlyJobs', () => { - it('should run the scheduled jobs', async () => { - await sut.handleNightlyJobs(); - - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.AssetDeleteCheck }, - { name: JobName.UserDeleteCheck }, - { name: JobName.PersonCleanup }, - { name: JobName.MemoryCleanup }, - { name: JobName.SessionCleanup }, - { name: JobName.AuditTableCleanup }, - { name: JobName.AuditLogCleanup }, - { name: JobName.MemoryGenerate }, - { name: JobName.UserSyncUsage }, - { name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }, - { name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }, - ]); - }); - }); - - describe('getAllJobStatus', () => { - it('should get all job statuses', async () => { - mocks.job.getJobCounts.mockResolvedValue({ - active: 1, - completed: 1, - failed: 1, - delayed: 1, - waiting: 1, - paused: 1, - }); - mocks.job.getQueueStatus.mockResolvedValue({ - isActive: true, - isPaused: true, - }); - - const expectedJobStatus = { - jobCounts: { - active: 1, - completed: 1, - delayed: 1, - failed: 1, - waiting: 1, - paused: 1, - }, - queueStatus: { - isActive: true, - isPaused: true, - }, - }; - - await expect(sut.getAllJobsStatus()).resolves.toEqual({ - [QueueName.BackgroundTask]: expectedJobStatus, - [QueueName.DuplicateDetection]: expectedJobStatus, - [QueueName.SmartSearch]: expectedJobStatus, - [QueueName.MetadataExtraction]: expectedJobStatus, - [QueueName.Search]: expectedJobStatus, - [QueueName.StorageTemplateMigration]: expectedJobStatus, - [QueueName.Migration]: expectedJobStatus, - [QueueName.ThumbnailGeneration]: expectedJobStatus, - [QueueName.VideoConversion]: expectedJobStatus, - [QueueName.FaceDetection]: expectedJobStatus, - [QueueName.FacialRecognition]: expectedJobStatus, - [QueueName.Sidecar]: expectedJobStatus, - [QueueName.Library]: expectedJobStatus, - [QueueName.Notification]: expectedJobStatus, - [QueueName.BackupDatabase]: expectedJobStatus, - [QueueName.Ocr]: expectedJobStatus, - }); - }); - }); - - describe('handleCommand', () => { - it('should handle a pause command', async () => { - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Pause, force: false }); - - expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction); - }); - - it('should handle a resume command', async () => { - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Resume, force: false }); - - expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction); - }); - - it('should handle an empty command', async () => { - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Empty, force: false }); - - expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction); - }); - - it('should not start a job that is already running', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); - - await expect( - sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }), - ).rejects.toBeInstanceOf(BadRequestException); - - expect(mocks.job.queue).not.toHaveBeenCalled(); - expect(mocks.job.queueAll).not.toHaveBeenCalled(); - }); - - it('should handle a start video conversion command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } }); - }); - - it('should handle a start storage template migration command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.StorageTemplateMigration, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration }); - }); - - it('should handle a start smart search command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.SmartSearch, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } }); - }); - - it('should handle a start metadata extraction command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.AssetExtractMetadataQueueAll, - data: { force: false }, - }); - }); - - it('should handle a start sidecar command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.Sidecar, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } }); - }); - - it('should handle a start thumbnail generation command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.ThumbnailGeneration, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.AssetGenerateThumbnailsQueueAll, - data: { force: false }, - }); - }); - - it('should handle a start face detection command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.FaceDetection, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } }); - }); - - it('should handle a start facial recognition command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.FacialRecognition, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } }); - }); - - it('should handle a start backup database command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.BackupDatabase, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } }); - }); - - it('should throw a bad request when an invalid queue is used', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await expect( - sut.handleCommand(QueueName.BackgroundTask, { command: JobCommand.Start, force: false }), - ).rejects.toBeInstanceOf(BadRequestException); - - expect(mocks.job.queue).not.toHaveBeenCalled(); - expect(mocks.job.queueAll).not.toHaveBeenCalled(); - }); - }); - describe('onJobRun', () => { it('should process a successful job', async () => { mocks.job.run.mockResolvedValue(JobStatus.Success); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index c483155b71..b57a203788 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,28 +1,12 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ClassConstructor } from 'class-transformer'; -import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; -import { - AssetType, - AssetVisibility, - BootstrapEventPriority, - CronJob, - DatabaseLock, - ImmichWorker, - JobCommand, - JobName, - JobStatus, - ManualJobName, - QueueCleanType, - QueueName, -} from 'src/enum'; -import { ArgOf, ArgsOf } from 'src/repositories/event.repository'; +import { JobCreateDto } from 'src/dtos/job.dto'; +import { AssetType, AssetVisibility, JobName, JobStatus, ManualJobName } from 'src/enum'; +import { ArgsOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; -import { ConcurrentQueueName, JobItem } from 'src/types'; +import { JobItem } from 'src/types'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; -import { handlePromiseError } from 'src/utils/misc'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { @@ -56,196 +40,12 @@ const asJobItem = (dto: JobCreateDto): JobItem => { } }; -const asNightlyTasksCron = (config: SystemConfig) => { - const [hours, minutes] = config.nightlyTasks.startTime.split(':').map(Number); - return `${minutes} ${hours} * * *`; -}; - @Injectable() export class JobService extends BaseService { - private services: ClassConstructor[] = []; - private nightlyJobsLock = false; - - @OnEvent({ name: 'ConfigInit' }) - async onConfigInit({ newConfig: config }: ArgOf<'ConfigInit'>) { - if (this.worker === ImmichWorker.Microservices) { - this.updateQueueConcurrency(config); - return; - } - - this.nightlyJobsLock = await this.databaseRepository.tryLock(DatabaseLock.NightlyJobs); - if (this.nightlyJobsLock) { - const cronExpression = asNightlyTasksCron(config); - this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); - this.cronRepository.create({ - name: CronJob.NightlyJobs, - expression: cronExpression, - start: true, - onTick: () => handlePromiseError(this.handleNightlyJobs(), this.logger), - }); - } - } - - @OnEvent({ name: 'ConfigUpdate', server: true }) - onConfigUpdate({ newConfig: config }: ArgOf<'ConfigUpdate'>) { - if (this.worker === ImmichWorker.Microservices) { - this.updateQueueConcurrency(config); - return; - } - - if (this.nightlyJobsLock) { - const cronExpression = asNightlyTasksCron(config); - this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); - this.cronRepository.update({ name: CronJob.NightlyJobs, expression: cronExpression, start: true }); - } - } - - @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.JobService }) - onBootstrap() { - this.jobRepository.setup(this.services); - if (this.worker === ImmichWorker.Microservices) { - this.jobRepository.startWorkers(); - } - } - - private updateQueueConcurrency(config: SystemConfig) { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - } - - setServices(services: ClassConstructor[]) { - this.services = services; - } - async create(dto: JobCreateDto): Promise { await this.jobRepository.queue(asJobItem(dto)); } - async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { - this.logger.debug(`Handling command: queue=${queueName},command=${dto.command},force=${dto.force}`); - - switch (dto.command) { - case JobCommand.Start: { - await this.start(queueName, dto); - break; - } - - case JobCommand.Pause: { - await this.jobRepository.pause(queueName); - break; - } - - case JobCommand.Resume: { - await this.jobRepository.resume(queueName); - break; - } - - case JobCommand.Empty: { - await this.jobRepository.empty(queueName); - break; - } - - case JobCommand.ClearFailed: { - const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.Failed); - this.logger.debug(`Cleared failed jobs: ${failedJobs}`); - break; - } - } - - return this.getJobStatus(queueName); - } - - async getJobStatus(queueName: QueueName): Promise { - const [jobCounts, queueStatus] = await Promise.all([ - this.jobRepository.getJobCounts(queueName), - this.jobRepository.getQueueStatus(queueName), - ]); - - return { jobCounts, queueStatus }; - } - - async getAllJobsStatus(): Promise { - const response = new AllJobStatusResponseDto(); - for (const queueName of Object.values(QueueName)) { - response[queueName] = await this.getJobStatus(queueName); - } - return response; - } - - private async start(name: QueueName, { force }: JobCommandDto): Promise { - const { isActive } = await this.jobRepository.getQueueStatus(name); - if (isActive) { - throw new BadRequestException(`Job is already running`); - } - - await this.eventRepository.emit('QueueStart', { name }); - - switch (name) { - case QueueName.VideoConversion: { - return this.jobRepository.queue({ name: JobName.AssetEncodeVideoQueueAll, data: { force } }); - } - - case QueueName.StorageTemplateMigration: { - return this.jobRepository.queue({ name: JobName.StorageTemplateMigration }); - } - - case QueueName.Migration: { - return this.jobRepository.queue({ name: JobName.FileMigrationQueueAll }); - } - - case QueueName.SmartSearch: { - return this.jobRepository.queue({ name: JobName.SmartSearchQueueAll, data: { force } }); - } - - case QueueName.DuplicateDetection: { - return this.jobRepository.queue({ name: JobName.AssetDetectDuplicatesQueueAll, data: { force } }); - } - - case QueueName.MetadataExtraction: { - return this.jobRepository.queue({ name: JobName.AssetExtractMetadataQueueAll, data: { force } }); - } - - case QueueName.Sidecar: { - return this.jobRepository.queue({ name: JobName.SidecarQueueAll, data: { force } }); - } - - case QueueName.ThumbnailGeneration: { - return this.jobRepository.queue({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force } }); - } - - case QueueName.FaceDetection: { - return this.jobRepository.queue({ name: JobName.AssetDetectFacesQueueAll, data: { force } }); - } - - case QueueName.FacialRecognition: { - return this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force } }); - } - - case QueueName.Library: { - return this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: { force } }); - } - - case QueueName.BackupDatabase: { - return this.jobRepository.queue({ name: JobName.DatabaseBackup, data: { force } }); - } - - case QueueName.Ocr: { - return this.jobRepository.queue({ name: JobName.OcrQueueAll, data: { force } }); - } - - default: { - throw new BadRequestException(`Invalid job name: ${name}`); - } - } - } - @OnEvent({ name: 'JobRun' }) async onJobRun(...[queueName, job]: ArgsOf<'JobRun'>) { try { @@ -262,50 +62,6 @@ export class JobService extends BaseService { } } - private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { - return ![ - QueueName.FacialRecognition, - QueueName.StorageTemplateMigration, - QueueName.DuplicateDetection, - QueueName.BackupDatabase, - ].includes(name); - } - - async handleNightlyJobs() { - const config = await this.getConfig({ withCache: false }); - const jobs: JobItem[] = []; - - if (config.nightlyTasks.databaseCleanup) { - jobs.push( - { name: JobName.AssetDeleteCheck }, - { name: JobName.UserDeleteCheck }, - { name: JobName.PersonCleanup }, - { name: JobName.MemoryCleanup }, - { name: JobName.SessionCleanup }, - { name: JobName.AuditTableCleanup }, - { name: JobName.AuditLogCleanup }, - ); - } - - if (config.nightlyTasks.generateMemories) { - jobs.push({ name: JobName.MemoryGenerate }); - } - - if (config.nightlyTasks.syncQuotaUsage) { - jobs.push({ name: JobName.UserSyncUsage }); - } - - if (config.nightlyTasks.missingThumbnails) { - jobs.push({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }); - } - - if (config.nightlyTasks.clusterNewFaces) { - jobs.push({ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }); - } - - await this.jobRepository.queueAll(jobs); - } - /** * Queue follow up jobs */ diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts new file mode 100644 index 0000000000..1cc53df644 --- /dev/null +++ b/server/src/services/queue.service.spec.ts @@ -0,0 +1,223 @@ +import { BadRequestException } from '@nestjs/common'; +import { defaults, SystemConfig } from 'src/config'; +import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum'; +import { QueueService } from 'src/services/queue.service'; +import { newTestService, ServiceMocks } from 'test/utils'; + +describe(QueueService.name, () => { + let sut: QueueService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut, mocks } = newTestService(QueueService)); + + mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); + + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.StorageTemplateMigration, 1); + }); + }); + + describe('handleNightlyJobs', () => { + it('should run the scheduled jobs', async () => { + await sut.handleNightlyJobs(); + + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetDeleteCheck }, + { name: JobName.UserDeleteCheck }, + { name: JobName.PersonCleanup }, + { name: JobName.MemoryCleanup }, + { name: JobName.SessionCleanup }, + { name: JobName.AuditTableCleanup }, + { name: JobName.AuditLogCleanup }, + { name: JobName.MemoryGenerate }, + { name: JobName.UserSyncUsage }, + { name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }, + { name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }, + ]); + }); + }); + + describe('getAllJobStatus', () => { + it('should get all job statuses', async () => { + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + completed: 1, + failed: 1, + delayed: 1, + waiting: 1, + paused: 1, + }); + mocks.job.getQueueStatus.mockResolvedValue({ + isActive: true, + isPaused: true, + }); + + const expectedJobStatus = { + jobCounts: { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + paused: 1, + }, + queueStatus: { + isActive: true, + isPaused: true, + }, + }; + + await expect(sut.getAll()).resolves.toEqual({ + [QueueName.BackgroundTask]: expectedJobStatus, + [QueueName.DuplicateDetection]: expectedJobStatus, + [QueueName.SmartSearch]: expectedJobStatus, + [QueueName.MetadataExtraction]: expectedJobStatus, + [QueueName.Search]: expectedJobStatus, + [QueueName.StorageTemplateMigration]: expectedJobStatus, + [QueueName.Migration]: expectedJobStatus, + [QueueName.ThumbnailGeneration]: expectedJobStatus, + [QueueName.VideoConversion]: expectedJobStatus, + [QueueName.FaceDetection]: expectedJobStatus, + [QueueName.FacialRecognition]: expectedJobStatus, + [QueueName.Sidecar]: expectedJobStatus, + [QueueName.Library]: expectedJobStatus, + [QueueName.Notification]: expectedJobStatus, + [QueueName.BackupDatabase]: expectedJobStatus, + [QueueName.Ocr]: expectedJobStatus, + }); + }); + }); + + describe('handleCommand', () => { + it('should handle a pause command', async () => { + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Pause, force: false }); + + expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction); + }); + + it('should handle a resume command', async () => { + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Resume, force: false }); + + expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction); + }); + + it('should handle an empty command', async () => { + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Empty, force: false }); + + expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction); + }); + + it('should not start a job that is already running', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); + + await expect( + sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + }); + + it('should handle a start video conversion command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } }); + }); + + it('should handle a start storage template migration command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.StorageTemplateMigration, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration }); + }); + + it('should handle a start smart search command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.SmartSearch, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } }); + }); + + it('should handle a start metadata extraction command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.AssetExtractMetadataQueueAll, + data: { force: false }, + }); + }); + + it('should handle a start sidecar command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.Sidecar, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } }); + }); + + it('should handle a start thumbnail generation command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.ThumbnailGeneration, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.AssetGenerateThumbnailsQueueAll, + data: { force: false }, + }); + }); + + it('should handle a start face detection command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.FaceDetection, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } }); + }); + + it('should handle a start facial recognition command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.FacialRecognition, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } }); + }); + + it('should handle a start backup database command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.BackupDatabase, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } }); + }); + + it('should throw a bad request when an invalid queue is used', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await expect( + sut.runCommand(QueueName.BackgroundTask, { command: QueueCommand.Start, force: false }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts new file mode 100644 index 0000000000..bea665e8fd --- /dev/null +++ b/server/src/services/queue.service.ts @@ -0,0 +1,250 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ClassConstructor } from 'class-transformer'; +import { SystemConfig } from 'src/config'; +import { OnEvent } from 'src/decorators'; +import { QueueCommandDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; +import { + BootstrapEventPriority, + CronJob, + DatabaseLock, + ImmichWorker, + JobName, + QueueCleanType, + QueueCommand, + QueueName, +} from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { BaseService } from 'src/services/base.service'; +import { ConcurrentQueueName, JobItem } from 'src/types'; +import { handlePromiseError } from 'src/utils/misc'; + +const asNightlyTasksCron = (config: SystemConfig) => { + const [hours, minutes] = config.nightlyTasks.startTime.split(':').map(Number); + return `${minutes} ${hours} * * *`; +}; + +@Injectable() +export class QueueService extends BaseService { + private services: ClassConstructor[] = []; + private nightlyJobsLock = false; + + @OnEvent({ name: 'ConfigInit' }) + async onConfigInit({ newConfig: config }: ArgOf<'ConfigInit'>) { + if (this.worker === ImmichWorker.Microservices) { + this.updateConcurrency(config); + return; + } + + this.nightlyJobsLock = await this.databaseRepository.tryLock(DatabaseLock.NightlyJobs); + if (this.nightlyJobsLock) { + const cronExpression = asNightlyTasksCron(config); + this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); + this.cronRepository.create({ + name: CronJob.NightlyJobs, + expression: cronExpression, + start: true, + onTick: () => handlePromiseError(this.handleNightlyJobs(), this.logger), + }); + } + } + + @OnEvent({ name: 'ConfigUpdate', server: true }) + onConfigUpdate({ newConfig: config }: ArgOf<'ConfigUpdate'>) { + if (this.worker === ImmichWorker.Microservices) { + this.updateConcurrency(config); + return; + } + + if (this.nightlyJobsLock) { + const cronExpression = asNightlyTasksCron(config); + this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); + this.cronRepository.update({ name: CronJob.NightlyJobs, expression: cronExpression, start: true }); + } + } + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.JobService }) + onBootstrap() { + this.jobRepository.setup(this.services); + if (this.worker === ImmichWorker.Microservices) { + this.jobRepository.startWorkers(); + } + } + + private updateConcurrency(config: SystemConfig) { + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + + setServices(services: ClassConstructor[]) { + this.services = services; + } + + async runCommand(name: QueueName, dto: QueueCommandDto): Promise { + this.logger.debug(`Handling command: queue=${name},command=${dto.command},force=${dto.force}`); + + switch (dto.command) { + case QueueCommand.Start: { + await this.start(name, dto); + break; + } + + case QueueCommand.Pause: { + await this.jobRepository.pause(name); + break; + } + + case QueueCommand.Resume: { + await this.jobRepository.resume(name); + break; + } + + case QueueCommand.Empty: { + await this.jobRepository.empty(name); + break; + } + + case QueueCommand.ClearFailed: { + const failedJobs = await this.jobRepository.clear(name, QueueCleanType.Failed); + this.logger.debug(`Cleared failed jobs: ${failedJobs}`); + break; + } + } + + return this.getByName(name); + } + + async getAll(): Promise { + const response = new QueuesResponseDto(); + for (const name of Object.values(QueueName)) { + response[name] = await this.getByName(name); + } + return response; + } + + async getByName(name: QueueName): Promise { + const [jobCounts, queueStatus] = await Promise.all([ + this.jobRepository.getJobCounts(name), + this.jobRepository.getQueueStatus(name), + ]); + + return { jobCounts, queueStatus }; + } + + private async start(name: QueueName, { force }: QueueCommandDto): Promise { + const { isActive } = await this.jobRepository.getQueueStatus(name); + if (isActive) { + throw new BadRequestException(`Job is already running`); + } + + await this.eventRepository.emit('QueueStart', { name }); + + switch (name) { + case QueueName.VideoConversion: { + return this.jobRepository.queue({ name: JobName.AssetEncodeVideoQueueAll, data: { force } }); + } + + case QueueName.StorageTemplateMigration: { + return this.jobRepository.queue({ name: JobName.StorageTemplateMigration }); + } + + case QueueName.Migration: { + return this.jobRepository.queue({ name: JobName.FileMigrationQueueAll }); + } + + case QueueName.SmartSearch: { + return this.jobRepository.queue({ name: JobName.SmartSearchQueueAll, data: { force } }); + } + + case QueueName.DuplicateDetection: { + return this.jobRepository.queue({ name: JobName.AssetDetectDuplicatesQueueAll, data: { force } }); + } + + case QueueName.MetadataExtraction: { + return this.jobRepository.queue({ name: JobName.AssetExtractMetadataQueueAll, data: { force } }); + } + + case QueueName.Sidecar: { + return this.jobRepository.queue({ name: JobName.SidecarQueueAll, data: { force } }); + } + + case QueueName.ThumbnailGeneration: { + return this.jobRepository.queue({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force } }); + } + + case QueueName.FaceDetection: { + return this.jobRepository.queue({ name: JobName.AssetDetectFacesQueueAll, data: { force } }); + } + + case QueueName.FacialRecognition: { + return this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force } }); + } + + case QueueName.Library: { + return this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: { force } }); + } + + case QueueName.BackupDatabase: { + return this.jobRepository.queue({ name: JobName.DatabaseBackup, data: { force } }); + } + + case QueueName.Ocr: { + return this.jobRepository.queue({ name: JobName.OcrQueueAll, data: { force } }); + } + + default: { + throw new BadRequestException(`Invalid job name: ${name}`); + } + } + } + + private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { + return ![ + QueueName.FacialRecognition, + QueueName.StorageTemplateMigration, + QueueName.DuplicateDetection, + QueueName.BackupDatabase, + ].includes(name); + } + + async handleNightlyJobs() { + const config = await this.getConfig({ withCache: false }); + const jobs: JobItem[] = []; + + if (config.nightlyTasks.databaseCleanup) { + jobs.push( + { name: JobName.AssetDeleteCheck }, + { name: JobName.UserDeleteCheck }, + { name: JobName.PersonCleanup }, + { name: JobName.MemoryCleanup }, + { name: JobName.SessionCleanup }, + { name: JobName.AuditTableCleanup }, + { name: JobName.AuditLogCleanup }, + ); + } + + if (config.nightlyTasks.generateMemories) { + jobs.push({ name: JobName.MemoryGenerate }); + } + + if (config.nightlyTasks.syncQuotaUsage) { + jobs.push({ name: JobName.UserSyncUsage }); + } + + if (config.nightlyTasks.missingThumbnails) { + jobs.push({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }); + } + + if (config.nightlyTasks.clusterNewFaces) { + jobs.push({ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }); + } + + await this.jobRepository.queueAll(jobs); + } +} diff --git a/web/src/lib/components/admin-settings/JobSettings.svelte b/web/src/lib/components/admin-settings/JobSettings.svelte index fb8a11b33b..94b4426dbb 100644 --- a/web/src/lib/components/admin-settings/JobSettings.svelte +++ b/web/src/lib/components/admin-settings/JobSettings.svelte @@ -4,8 +4,8 @@ import { SettingInputFieldType } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; - import { getJobName } from '$lib/utils'; - import { JobName, type SystemConfigJobDto } from '@immich/sdk'; + import { getQueueName } from '$lib/utils'; + import { QueueName, type SystemConfigJobDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -13,18 +13,18 @@ const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); - const jobNames = [ - JobName.ThumbnailGeneration, - JobName.MetadataExtraction, - JobName.Library, - JobName.Sidecar, - JobName.SmartSearch, - JobName.FaceDetection, - JobName.FacialRecognition, - JobName.VideoConversion, - JobName.StorageTemplateMigration, - JobName.Migration, - JobName.Ocr, + const queueNames = [ + QueueName.ThumbnailGeneration, + QueueName.MetadataExtraction, + QueueName.Library, + QueueName.Sidecar, + QueueName.SmartSearch, + QueueName.FaceDetection, + QueueName.FacialRecognition, + QueueName.VideoConversion, + QueueName.StorageTemplateMigration, + QueueName.Migration, + QueueName.Ocr, ]; function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto { @@ -35,22 +35,22 @@
event.preventDefault()}> - {#each jobNames as jobName (jobName)} + {#each queueNames as queueName (queueName)}
- {#if isSystemConfigJobDto(jobName)} + {#if isSystemConfigJobDto(queueName)} {:else} import Badge from '$lib/elements/Badge.svelte'; import { locale } from '$lib/stores/preferences.store'; - import { JobCommand, type JobCommandDto, type JobCountsDto, type QueueStatusDto } from '@immich/sdk'; + import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusDto } from '@immich/sdk'; import { Icon, IconButton } from '@immich/ui'; import { mdiAlertCircle, @@ -22,21 +22,21 @@ title: string; subtitle: string | undefined; description: Component | undefined; - jobCounts: JobCountsDto; + statistics: QueueStatisticsDto; queueStatus: QueueStatusDto; icon: string; disabled?: boolean; allText: string | undefined; refreshText: string | undefined; missingText: string; - onCommand: (command: JobCommandDto) => void; + onCommand: (command: QueueCommandDto) => void; } let { title, subtitle, description, - jobCounts, + statistics, queueStatus, icon, disabled = false, @@ -46,7 +46,7 @@ onCommand, }: Props = $props(); - let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed); + let waitingCount = $derived(statistics.waiting + statistics.paused + statistics.delayed); let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused); let multipleButtons = $derived(allText || refreshText); @@ -67,11 +67,11 @@ {title}
- {#if jobCounts.failed > 0} + {#if statistics.failed > 0}
- {$t('admin.jobs_failed', { values: { jobCount: jobCounts.failed.toLocaleString($locale) } })} + {$t('admin.jobs_failed', { values: { jobCount: statistics.failed.toLocaleString($locale) } })} onCommand({ command: JobCommand.ClearFailed, force: false })} + onclick={() => onCommand({ command: QueueCommand.ClearFailed, force: false })} />
{/if} - {#if jobCounts.delayed > 0} + {#if statistics.delayed > 0} - {$t('admin.jobs_delayed', { values: { jobCount: jobCounts.delayed.toLocaleString($locale) } })} + {$t('admin.jobs_delayed', { values: { jobCount: statistics.delayed.toLocaleString($locale) } })} {/if} @@ -111,7 +111,7 @@ >

{$t('active')}

- {jobCounts.active.toLocaleString($locale)} + {statistics.active.toLocaleString($locale)}

@@ -131,7 +131,7 @@ onCommand({ command: JobCommand.Start, force: false })} + onClick={() => onCommand({ command: QueueCommand.Start, force: false })} > {$t('disabled')} @@ -140,20 +140,20 @@ {#if !disabled && !isIdle} {#if waitingCount > 0} - onCommand({ command: JobCommand.Empty, force: false })}> + onCommand({ command: QueueCommand.Empty, force: false })}> {$t('clear')} {/if} {#if queueStatus.isPaused} {@const size = waitingCount > 0 ? '24' : '48'} - onCommand({ command: JobCommand.Resume, force: false })}> + onCommand({ command: QueueCommand.Resume, force: false })}> {$t('resume')} {:else} - onCommand({ command: JobCommand.Pause, force: false })}> + onCommand({ command: QueueCommand.Pause, force: false })}> {$t('pause')} @@ -162,25 +162,25 @@ {#if !disabled && multipleButtons && isIdle} {#if allText} - onCommand({ command: JobCommand.Start, force: true })}> + onCommand({ command: QueueCommand.Start, force: true })}> {allText} {/if} {#if refreshText} - onCommand({ command: JobCommand.Start, force: undefined })}> + onCommand({ command: QueueCommand.Start, force: undefined })}> {refreshText} {/if} - onCommand({ command: JobCommand.Start, force: false })}> + onCommand({ command: QueueCommand.Start, force: false })}> {missingText} {/if} {#if !disabled && !multipleButtons && isIdle} - onCommand({ command: JobCommand.Start, force: false })}> + onCommand({ command: QueueCommand.Start, force: false })}> {missingText} diff --git a/web/src/lib/components/jobs/JobsPanel.svelte b/web/src/lib/components/jobs/JobsPanel.svelte index e204c76648..99dfef3b59 100644 --- a/web/src/lib/components/jobs/JobsPanel.svelte +++ b/web/src/lib/components/jobs/JobsPanel.svelte @@ -1,8 +1,14 @@
{#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }] (jobName)} - {@const { jobCounts, queueStatus } = jobs[jobName]} + {@const { jobCounts: statistics, queueStatus } = jobs[jobName]} (handleCommandOverride || handleCommand)(jobName, command)} /> diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 6e0a216477..87f0d7e7bf 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -5,8 +5,8 @@ import { handleError } from '$lib/utils/handle-error'; import { AssetJobName, AssetMediaSize, - JobName, MemoryType, + QueueName, finishOAuth, getAssetOriginalPath, getAssetPlaybackPath, @@ -143,28 +143,28 @@ export const downloadRequest = (options: DownloadRequestOptions }); }; -export const getJobName = derived(t, ($t) => { - return (jobName: JobName) => { - const names: Record = { - [JobName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'), - [JobName.MetadataExtraction]: $t('admin.metadata_extraction_job'), - [JobName.Sidecar]: $t('admin.sidecar_job'), - [JobName.SmartSearch]: $t('admin.machine_learning_smart_search'), - [JobName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'), - [JobName.FaceDetection]: $t('admin.face_detection'), - [JobName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'), - [JobName.VideoConversion]: $t('admin.video_conversion_job'), - [JobName.StorageTemplateMigration]: $t('admin.storage_template_migration'), - [JobName.Migration]: $t('admin.migration_job'), - [JobName.BackgroundTask]: $t('admin.background_task_job'), - [JobName.Search]: $t('search'), - [JobName.Library]: $t('external_libraries'), - [JobName.Notifications]: $t('notifications'), - [JobName.BackupDatabase]: $t('admin.backup_database'), - [JobName.Ocr]: $t('admin.machine_learning_ocr'), +export const getQueueName = derived(t, ($t) => { + return (name: QueueName) => { + const names: Record = { + [QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'), + [QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'), + [QueueName.Sidecar]: $t('admin.sidecar_job'), + [QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'), + [QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'), + [QueueName.FaceDetection]: $t('admin.face_detection'), + [QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'), + [QueueName.VideoConversion]: $t('admin.video_conversion_job'), + [QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'), + [QueueName.Migration]: $t('admin.migration_job'), + [QueueName.BackgroundTask]: $t('admin.background_task_job'), + [QueueName.Search]: $t('search'), + [QueueName.Library]: $t('external_libraries'), + [QueueName.Notifications]: $t('notifications'), + [QueueName.BackupDatabase]: $t('admin.backup_database'), + [QueueName.Ocr]: $t('admin.machine_learning_ocr'), }; - return names[jobName]; + return names[name]; }; }); diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 1204ff901f..84586a8af0 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -5,13 +5,7 @@ import JobCreateModal from '$lib/modals/JobCreateModal.svelte'; import { asyncTimeout } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; - import { - getAllJobsStatus, - JobCommand, - sendJobCommand, - type AllJobStatusResponseDto, - type JobName, - } from '@immich/sdk'; + import { getQueuesLegacy, QueueCommand, QueueName, runQueueCommandLegacy, type QueuesResponseDto } from '@immich/sdk'; import { Button, HStack, modalManager, Text } from '@immich/ui'; import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; @@ -24,23 +18,23 @@ let { data }: Props = $props(); - let jobs: AllJobStatusResponseDto | undefined = $state(); + let jobs: QueuesResponseDto | undefined = $state(); let running = true; const pausedJobs = $derived( Object.entries(jobs ?? {}) - .filter(([_, jobStatus]) => jobStatus.queueStatus?.isPaused) - .map(([jobName]) => jobName as JobName), + .filter(([_, queue]) => queue.queueStatus?.isPaused) + .map(([name]) => name as QueueName), ); const handleResumePausedJobs = async () => { try { - for (const jobName of pausedJobs) { - await sendJobCommand({ id: jobName, jobCommandDto: { command: JobCommand.Resume, force: false } }); + for (const name of pausedJobs) { + await runQueueCommandLegacy({ name, queueCommandDto: { command: QueueCommand.Resume, force: false } }); } // Refresh jobs status immediately after resuming - jobs = await getAllJobsStatus(); + jobs = await getQueuesLegacy(); } catch (error) { handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } })); } @@ -48,7 +42,7 @@ onMount(async () => { while (running) { - jobs = await getAllJobsStatus(); + jobs = await getQueuesLegacy(); await asyncTimeout(5000); } }); diff --git a/web/src/routes/admin/jobs-status/+page.ts b/web/src/routes/admin/jobs-status/+page.ts index 0d4ec8b41f..90057ff969 100644 --- a/web/src/routes/admin/jobs-status/+page.ts +++ b/web/src/routes/admin/jobs-status/+page.ts @@ -1,12 +1,12 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getAllJobsStatus } from '@immich/sdk'; +import { getQueuesLegacy } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url, { admin: true }); - const jobs = await getAllJobsStatus(); + const jobs = await getQueuesLegacy(); const $t = await getFormatter(); return { diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 039afc97b7..600b6ff048 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -17,10 +17,10 @@ getAllLibraries, getLibraryStatistics, getUserAdmin, - JobCommand, - JobName, + QueueCommand, + QueueName, + runQueueCommandLegacy, scanLibrary, - sendJobCommand, updateLibrary, type LibraryResponseDto, type LibraryStatsResponseDto, @@ -151,7 +151,7 @@ const handleScanAll = async () => { try { - await sendJobCommand({ id: JobName.Library, jobCommandDto: { command: JobCommand.Start } }); + await runQueueCommandLegacy({ name: QueueName.Library, queueCommandDto: { command: QueueCommand.Start } }); toastManager.info($t('admin.refreshing_all_libraries')); } catch (error) { From 4dcc04946584b7822faba82644cf8644d4d7da8d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Nov 2025 14:05:05 -0600 Subject: [PATCH 92/93] feat: workflow foundation (#23621) * feat: plugins * feat: table definition * feat: type and migration * feat: add repositories * feat: validate manifest with class-validator and load manifest info to database * feat: workflow/plugin controller/service layer * feat: implement workflow logic * feat: make trigger static * feat: dynamical instantiate plugin instances * fix: access control and helper script * feat: it works * chore: simplify * refactor: refactor and use queue for workflow execution * refactor: remove unsused property in plugin-schema * build wasm in prod * feat: plugin loader in transaction * fix: docker build arm64 * generated files * shell check * fix tests * fix: waiting for migration to finish before loading plugin * remove context reassignment * feat: use mise to manage extism tools (#23760) * pr feedback * refactor: create workflow now including create filters and actions * feat: workflow medium tests * fix: broken medium test * feat: medium tests * chore: unify workflow job * sign user id with jwt * chore: query plugin with filters and action * chore: read manifest in repository * chore: load manifest from server configs * merge main * feat: endpoint documentation * pr feedback * load plugin from absolute path * refactor:handle trigger * throw error and return early * pr feedback * unify plugin services * fix: plugins code * clean up * remove triggerConfig * clean up * displayName and methodName --------- Co-authored-by: Jason Rasmussen Co-authored-by: bo0tzz --- docker/docker-compose.dev.yml | 1 + i18n/en.json | 1 + mobile/openapi/README.md | 19 + mobile/openapi/lib/api.dart | 14 + mobile/openapi/lib/api/plugins_api.dart | 126 +++ mobile/openapi/lib/api/workflows_api.dart | 292 +++++++ mobile/openapi/lib/api_client.dart | 24 + mobile/openapi/lib/api_helper.dart | 6 + mobile/openapi/lib/model/permission.dart | 24 + .../lib/model/plugin_action_response_dto.dart | 151 ++++ mobile/openapi/lib/model/plugin_context.dart | 88 ++ .../lib/model/plugin_filter_response_dto.dart | 151 ++++ .../lib/model/plugin_response_dto.dart | 171 ++++ .../lib/model/plugin_trigger_type.dart | 85 ++ mobile/openapi/lib/model/queue_name.dart | 3 + .../lib/model/queues_response_dto.dart | 14 +- .../lib/model/system_config_job_dto.dart | 14 +- .../lib/model/workflow_action_item_dto.dart | 116 +++ .../model/workflow_action_response_dto.dart | 135 ++++ .../lib/model/workflow_create_dto.dart | 157 ++++ .../lib/model/workflow_filter_item_dto.dart | 116 +++ .../model/workflow_filter_response_dto.dart | 135 ++++ .../lib/model/workflow_response_dto.dart | 241 ++++++ .../lib/model/workflow_update_dto.dart | 156 ++++ open-api/immich-openapi-specs.json | 757 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 194 ++++- plugins/.gitignore | 2 + plugins/LICENSE | 26 + plugins/esbuild.js | 12 + plugins/manifest.json | 127 +++ plugins/mise.toml | 11 + plugins/package-lock.json | 443 ++++++++++ plugins/package.json | 19 + plugins/src/index.d.ts | 12 + plugins/src/index.ts | 71 ++ plugins/tsconfig.json | 24 + pnpm-lock.yaml | 130 +++ pnpm-workspace.yaml | 1 + server/Dockerfile | 20 + server/package.json | 4 + server/src/config.ts | 1 + server/src/constants.ts | 4 + server/src/controllers/index.ts | 4 + server/src/controllers/plugin.controller.ts | 36 + server/src/controllers/workflow.controller.ts | 76 ++ server/src/database.ts | 55 ++ server/src/dtos/env.dto.ts | 7 + server/src/dtos/plugin-manifest.dto.ts | 110 +++ server/src/dtos/plugin.dto.ts | 77 ++ server/src/dtos/queue.dto.ts | 3 + server/src/dtos/system-config.dto.ts | 6 + server/src/dtos/workflow.dto.ts | 120 +++ server/src/enum.ts | 27 + server/src/plugins.ts | 37 + server/src/queries/access.repository.sql | 9 + server/src/queries/plugin.repository.sql | 159 ++++ server/src/queries/workflow.repository.sql | 68 ++ server/src/repositories/access.repository.ts | 22 + server/src/repositories/config.repository.ts | 12 + server/src/repositories/crypto.repository.ts | 9 + server/src/repositories/event.repository.ts | 2 + server/src/repositories/index.ts | 4 + server/src/repositories/plugin.repository.ts | 176 ++++ server/src/repositories/storage.repository.ts | 4 + .../src/repositories/workflow.repository.ts | 139 ++++ server/src/schema/index.ts | 16 + ...762297277677-AddPluginAndWorkflowTables.ts | 113 +++ server/src/schema/tables/plugin.table.ts | 95 +++ server/src/schema/tables/workflow.table.ts | 78 ++ server/src/services/asset-media.service.ts | 3 + server/src/services/base.service.ts | 7 + server/src/services/index.ts | 4 + server/src/services/plugin-host.functions.ts | 120 +++ server/src/services/plugin.service.ts | 317 ++++++++ server/src/services/queue.service.spec.ts | 3 +- .../services/system-config.service.spec.ts | 1 + server/src/services/workflow.service.ts | 159 ++++ server/src/types.ts | 24 +- server/src/types/plugin-schema.types.ts | 35 + server/src/utils/access.ts | 6 + server/test/medium.factory.ts | 10 +- .../specs/services/plugin.service.spec.ts | 308 +++++++ .../specs/services/workflow.service.spec.ts | 697 ++++++++++++++++ .../repositories/access.repository.mock.ts | 4 + .../repositories/config.repository.mock.ts | 6 + .../repositories/crypto.repository.mock.ts | 2 + .../repositories/storage.repository.mock.ts | 1 + server/test/utils.ts | 8 + web/src/lib/utils.ts | 1 + 89 files changed, 7264 insertions(+), 14 deletions(-) create mode 100644 mobile/openapi/lib/api/plugins_api.dart create mode 100644 mobile/openapi/lib/api/workflows_api.dart create mode 100644 mobile/openapi/lib/model/plugin_action_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_context.dart create mode 100644 mobile/openapi/lib/model/plugin_filter_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_trigger_type.dart create mode 100644 mobile/openapi/lib/model/workflow_action_item_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_action_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_create_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_filter_item_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_filter_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_update_dto.dart create mode 100644 plugins/.gitignore create mode 100644 plugins/LICENSE create mode 100644 plugins/esbuild.js create mode 100644 plugins/manifest.json create mode 100644 plugins/mise.toml create mode 100644 plugins/package-lock.json create mode 100644 plugins/package.json create mode 100644 plugins/src/index.d.ts create mode 100644 plugins/src/index.ts create mode 100644 plugins/tsconfig.json create mode 100644 server/src/controllers/plugin.controller.ts create mode 100644 server/src/controllers/workflow.controller.ts create mode 100644 server/src/dtos/plugin-manifest.dto.ts create mode 100644 server/src/dtos/plugin.dto.ts create mode 100644 server/src/dtos/workflow.dto.ts create mode 100644 server/src/plugins.ts create mode 100644 server/src/queries/plugin.repository.sql create mode 100644 server/src/queries/workflow.repository.sql create mode 100644 server/src/repositories/plugin.repository.ts create mode 100644 server/src/repositories/workflow.repository.ts create mode 100644 server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts create mode 100644 server/src/schema/tables/plugin.table.ts create mode 100644 server/src/schema/tables/workflow.table.ts create mode 100644 server/src/services/plugin-host.functions.ts create mode 100644 server/src/services/plugin.service.ts create mode 100644 server/src/services/workflow.service.ts create mode 100644 server/src/types/plugin-schema.types.ts create mode 100644 server/test/medium/specs/services/plugin.service.spec.ts create mode 100644 server/test/medium/specs/services/workflow.service.spec.ts diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5968c5bb3a..e2fb8fbc30 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -41,6 +41,7 @@ services: - app-node_modules:/usr/src/app/node_modules - sveltekit:/usr/src/app/web/.svelte-kit - coverage:/usr/src/app/web/coverage + - ../plugins:/build/corePlugin env_file: - .env environment: diff --git a/i18n/en.json b/i18n/en.json index f0b10d2ac1..ce999793d4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2181,6 +2181,7 @@ "welcome": "Welcome", "welcome_to_immich": "Welcome to Immich", "wifi_name": "Wi-Fi Name", + "workflow": "Workflow", "wrong_pin_code": "Wrong PIN code", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5e93c571bd..4e34f66a81 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -194,6 +194,8 @@ Class | Method | HTTP request | Description *PeopleApi* | [**reassignFaces**](doc//PeopleApi.md#reassignfaces) | **PUT** /people/{id}/reassign | Reassign faces *PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people *PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person +*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin +*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions @@ -295,6 +297,11 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | Update user preferences *ViewsApi* | [**getAssetsByOriginalPath**](doc//ViewsApi.md#getassetsbyoriginalpath) | **GET** /view/folder | Retrieve assets by original path *ViewsApi* | [**getUniqueOriginalPaths**](doc//ViewsApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | Retrieve unique paths +*WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow +*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow +*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow +*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows +*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow ## Documentation For Models @@ -444,6 +451,11 @@ Class | Method | HTTP request | Description - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) + - [PluginActionResponseDto](doc//PluginActionResponseDto.md) + - [PluginContext](doc//PluginContext.md) + - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md) + - [PluginResponseDto](doc//PluginResponseDto.md) + - [PluginTriggerType](doc//PluginTriggerType.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueCommand](doc//QueueCommand.md) @@ -603,6 +615,13 @@ Class | Method | HTTP request | Description - [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md) - [VideoCodec](doc//VideoCodec.md) - [VideoContainer](doc//VideoContainer.md) + - [WorkflowActionItemDto](doc//WorkflowActionItemDto.md) + - [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md) + - [WorkflowCreateDto](doc//WorkflowCreateDto.md) + - [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md) + - [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md) + - [WorkflowResponseDto](doc//WorkflowResponseDto.md) + - [WorkflowUpdateDto](doc//WorkflowUpdateDto.md) ## Documentation For Authorization diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index c64295837c..f3db370c92 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -48,6 +48,7 @@ part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart'; part 'api/partners_api.dart'; part 'api/people_api.dart'; +part 'api/plugins_api.dart'; part 'api/search_api.dart'; part 'api/server_api.dart'; part 'api/sessions_api.dart'; @@ -62,6 +63,7 @@ part 'api/trash_api.dart'; part 'api/users_api.dart'; part 'api/users_admin_api.dart'; part 'api/views_api.dart'; +part 'api/workflows_api.dart'; part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; @@ -208,6 +210,11 @@ part 'model/pin_code_change_dto.dart'; part 'model/pin_code_reset_dto.dart'; part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; +part 'model/plugin_action_response_dto.dart'; +part 'model/plugin_context.dart'; +part 'model/plugin_filter_response_dto.dart'; +part 'model/plugin_response_dto.dart'; +part 'model/plugin_trigger_type.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_command.dart'; @@ -367,6 +374,13 @@ part 'model/validate_library_response_dto.dart'; part 'model/version_check_state_response_dto.dart'; part 'model/video_codec.dart'; part 'model/video_container.dart'; +part 'model/workflow_action_item_dto.dart'; +part 'model/workflow_action_response_dto.dart'; +part 'model/workflow_create_dto.dart'; +part 'model/workflow_filter_item_dto.dart'; +part 'model/workflow_filter_response_dto.dart'; +part 'model/workflow_response_dto.dart'; +part 'model/workflow_update_dto.dart'; /// An [ApiClient] instance that uses the default values obtained from diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart new file mode 100644 index 0000000000..264d3049e8 --- /dev/null +++ b/mobile/openapi/lib/api/plugins_api.dart @@ -0,0 +1,126 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PluginsApi { + PluginsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Retrieve a plugin + /// + /// Retrieve information about a specific plugin by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getPluginWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve a plugin + /// + /// Retrieve information about a specific plugin by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getPlugin(String id,) async { + final response = await getPluginWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PluginResponseDto',) as PluginResponseDto; + + } + return null; + } + + /// List all plugins + /// + /// Retrieve a list of plugins available to the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + Future getPluginsWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all plugins + /// + /// Retrieve a list of plugins available to the authenticated user. + Future?> getPlugins() async { + final response = await getPluginsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } +} diff --git a/mobile/openapi/lib/api/workflows_api.dart b/mobile/openapi/lib/api/workflows_api.dart new file mode 100644 index 0000000000..c589ec9823 --- /dev/null +++ b/mobile/openapi/lib/api/workflows_api.dart @@ -0,0 +1,292 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class WorkflowsApi { + WorkflowsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Create a workflow + /// + /// Create a new workflow, the workflow can also be created with empty filters and actions. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [WorkflowCreateDto] workflowCreateDto (required): + Future createWorkflowWithHttpInfo(WorkflowCreateDto workflowCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows'; + + // ignore: prefer_final_locals + Object? postBody = workflowCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Create a workflow + /// + /// Create a new workflow, the workflow can also be created with empty filters and actions. + /// + /// Parameters: + /// + /// * [WorkflowCreateDto] workflowCreateDto (required): + Future createWorkflow(WorkflowCreateDto workflowCreateDto,) async { + final response = await createWorkflowWithHttpInfo(workflowCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto; + + } + return null; + } + + /// Delete a workflow + /// + /// Delete a workflow by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteWorkflowWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete a workflow + /// + /// Delete a workflow by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteWorkflow(String id,) async { + final response = await deleteWorkflowWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Retrieve a workflow + /// + /// Retrieve information about a specific workflow by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflowWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve a workflow + /// + /// Retrieve information about a specific workflow by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflow(String id,) async { + final response = await getWorkflowWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto; + + } + return null; + } + + /// List all workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + Future getWorkflowsWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + Future?> getWorkflows() async { + final response = await getWorkflowsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Update a workflow + /// + /// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [WorkflowUpdateDto] workflowUpdateDto (required): + Future updateWorkflowWithHttpInfo(String id, WorkflowUpdateDto workflowUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = workflowUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Update a workflow + /// + /// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [WorkflowUpdateDto] workflowUpdateDto (required): + Future updateWorkflow(String id, WorkflowUpdateDto workflowUpdateDto,) async { + final response = await updateWorkflowWithHttpInfo(id, workflowUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 373a4b9d8b..91dc670d12 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -472,6 +472,16 @@ class ApiClient { return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': return PlacesResponseDto.fromJson(value); + case 'PluginActionResponseDto': + return PluginActionResponseDto.fromJson(value); + case 'PluginContext': + return PluginContextTypeTransformer().decode(value); + case 'PluginFilterResponseDto': + return PluginFilterResponseDto.fromJson(value); + case 'PluginResponseDto': + return PluginResponseDto.fromJson(value); + case 'PluginTriggerType': + return PluginTriggerTypeTypeTransformer().decode(value); case 'PurchaseResponse': return PurchaseResponse.fromJson(value); case 'PurchaseUpdate': @@ -790,6 +800,20 @@ class ApiClient { return VideoCodecTypeTransformer().decode(value); case 'VideoContainer': return VideoContainerTypeTransformer().decode(value); + case 'WorkflowActionItemDto': + return WorkflowActionItemDto.fromJson(value); + case 'WorkflowActionResponseDto': + return WorkflowActionResponseDto.fromJson(value); + case 'WorkflowCreateDto': + return WorkflowCreateDto.fromJson(value); + case 'WorkflowFilterItemDto': + return WorkflowFilterItemDto.fromJson(value); + case 'WorkflowFilterResponseDto': + return WorkflowFilterResponseDto.fromJson(value); + case 'WorkflowResponseDto': + return WorkflowResponseDto.fromJson(value); + case 'WorkflowUpdateDto': + return WorkflowUpdateDto.fromJson(value); default: dynamic match; if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 5c21009a0b..4b33a07214 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -121,6 +121,12 @@ String parameterToString(dynamic value) { if (value is Permission) { return PermissionTypeTransformer().encode(value).toString(); } + if (value is PluginContext) { + return PluginContextTypeTransformer().encode(value).toString(); + } + if (value is PluginTriggerType) { + return PluginTriggerTypeTypeTransformer().encode(value).toString(); + } if (value is QueueCommand) { return QueueCommandTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index e05c3e84bc..8b05de523b 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -98,6 +98,10 @@ class Permission { static const pinCodePeriodCreate = Permission._(r'pinCode.create'); static const pinCodePeriodUpdate = Permission._(r'pinCode.update'); static const pinCodePeriodDelete = Permission._(r'pinCode.delete'); + static const pluginPeriodCreate = Permission._(r'plugin.create'); + static const pluginPeriodRead = Permission._(r'plugin.read'); + static const pluginPeriodUpdate = Permission._(r'plugin.update'); + static const pluginPeriodDelete = Permission._(r'plugin.delete'); static const serverPeriodAbout = Permission._(r'server.about'); static const serverPeriodApkLinks = Permission._(r'server.apkLinks'); static const serverPeriodStorage = Permission._(r'server.storage'); @@ -147,6 +151,10 @@ class Permission { static const userProfileImagePeriodRead = Permission._(r'userProfileImage.read'); static const userProfileImagePeriodUpdate = Permission._(r'userProfileImage.update'); static const userProfileImagePeriodDelete = Permission._(r'userProfileImage.delete'); + static const workflowPeriodCreate = Permission._(r'workflow.create'); + static const workflowPeriodRead = Permission._(r'workflow.read'); + static const workflowPeriodUpdate = Permission._(r'workflow.update'); + static const workflowPeriodDelete = Permission._(r'workflow.delete'); static const adminUserPeriodCreate = Permission._(r'adminUser.create'); static const adminUserPeriodRead = Permission._(r'adminUser.read'); static const adminUserPeriodUpdate = Permission._(r'adminUser.update'); @@ -231,6 +239,10 @@ class Permission { pinCodePeriodCreate, pinCodePeriodUpdate, pinCodePeriodDelete, + pluginPeriodCreate, + pluginPeriodRead, + pluginPeriodUpdate, + pluginPeriodDelete, serverPeriodAbout, serverPeriodApkLinks, serverPeriodStorage, @@ -280,6 +292,10 @@ class Permission { userProfileImagePeriodRead, userProfileImagePeriodUpdate, userProfileImagePeriodDelete, + workflowPeriodCreate, + workflowPeriodRead, + workflowPeriodUpdate, + workflowPeriodDelete, adminUserPeriodCreate, adminUserPeriodRead, adminUserPeriodUpdate, @@ -399,6 +415,10 @@ class PermissionTypeTransformer { case r'pinCode.create': return Permission.pinCodePeriodCreate; case r'pinCode.update': return Permission.pinCodePeriodUpdate; case r'pinCode.delete': return Permission.pinCodePeriodDelete; + case r'plugin.create': return Permission.pluginPeriodCreate; + case r'plugin.read': return Permission.pluginPeriodRead; + case r'plugin.update': return Permission.pluginPeriodUpdate; + case r'plugin.delete': return Permission.pluginPeriodDelete; case r'server.about': return Permission.serverPeriodAbout; case r'server.apkLinks': return Permission.serverPeriodApkLinks; case r'server.storage': return Permission.serverPeriodStorage; @@ -448,6 +468,10 @@ class PermissionTypeTransformer { case r'userProfileImage.read': return Permission.userProfileImagePeriodRead; case r'userProfileImage.update': return Permission.userProfileImagePeriodUpdate; case r'userProfileImage.delete': return Permission.userProfileImagePeriodDelete; + case r'workflow.create': return Permission.workflowPeriodCreate; + case r'workflow.read': return Permission.workflowPeriodRead; + case r'workflow.update': return Permission.workflowPeriodUpdate; + case r'workflow.delete': return Permission.workflowPeriodDelete; case r'adminUser.create': return Permission.adminUserPeriodCreate; case r'adminUser.read': return Permission.adminUserPeriodRead; case r'adminUser.update': return Permission.adminUserPeriodUpdate; diff --git a/mobile/openapi/lib/model/plugin_action_response_dto.dart b/mobile/openapi/lib/model/plugin_action_response_dto.dart new file mode 100644 index 0000000000..75b23fc8a4 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_action_response_dto.dart @@ -0,0 +1,151 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PluginActionResponseDto { + /// Returns a new [PluginActionResponseDto] instance. + PluginActionResponseDto({ + required this.description, + required this.id, + required this.methodName, + required this.pluginId, + required this.schema, + this.supportedContexts = const [], + required this.title, + }); + + String description; + + String id; + + String methodName; + + String pluginId; + + Object? schema; + + List supportedContexts; + + String title; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto && + other.description == description && + other.id == id && + other.methodName == methodName && + other.pluginId == pluginId && + other.schema == schema && + _deepEquality.equals(other.supportedContexts, supportedContexts) && + other.title == title; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description.hashCode) + + (id.hashCode) + + (methodName.hashCode) + + (pluginId.hashCode) + + (schema == null ? 0 : schema!.hashCode) + + (supportedContexts.hashCode) + + (title.hashCode); + + @override + String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; + + Map toJson() { + final json = {}; + json[r'description'] = this.description; + json[r'id'] = this.id; + json[r'methodName'] = this.methodName; + json[r'pluginId'] = this.pluginId; + if (this.schema != null) { + json[r'schema'] = this.schema; + } else { + // json[r'schema'] = null; + } + json[r'supportedContexts'] = this.supportedContexts; + json[r'title'] = this.title; + return json; + } + + /// Returns a new [PluginActionResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginActionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginActionResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginActionResponseDto( + description: mapValueOfType(json, r'description')!, + id: mapValueOfType(json, r'id')!, + methodName: mapValueOfType(json, r'methodName')!, + pluginId: mapValueOfType(json, r'pluginId')!, + schema: mapValueOfType(json, r'schema'), + supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']), + title: mapValueOfType(json, r'title')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginActionResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginActionResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginActionResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'id', + 'methodName', + 'pluginId', + 'schema', + 'supportedContexts', + 'title', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_context.dart b/mobile/openapi/lib/model/plugin_context.dart new file mode 100644 index 0000000000..efb701c7d0 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_context.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PluginContext { + /// Instantiate a new enum with the provided [value]. + const PluginContext._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const asset = PluginContext._(r'asset'); + static const album = PluginContext._(r'album'); + static const person = PluginContext._(r'person'); + + /// List of all possible values in this [enum][PluginContext]. + static const values = [ + asset, + album, + person, + ]; + + static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginContext.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [PluginContext] to String, +/// and [decode] dynamic data back to [PluginContext]. +class PluginContextTypeTransformer { + factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._(); + + const PluginContextTypeTransformer._(); + + String encode(PluginContext data) => data.value; + + /// Decodes a [dynamic value][data] to a PluginContext. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + PluginContext? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'asset': return PluginContext.asset; + case r'album': return PluginContext.album; + case r'person': return PluginContext.person; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PluginContextTypeTransformer] instance. + static PluginContextTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/plugin_filter_response_dto.dart b/mobile/openapi/lib/model/plugin_filter_response_dto.dart new file mode 100644 index 0000000000..8ed6acec78 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_filter_response_dto.dart @@ -0,0 +1,151 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PluginFilterResponseDto { + /// Returns a new [PluginFilterResponseDto] instance. + PluginFilterResponseDto({ + required this.description, + required this.id, + required this.methodName, + required this.pluginId, + required this.schema, + this.supportedContexts = const [], + required this.title, + }); + + String description; + + String id; + + String methodName; + + String pluginId; + + Object? schema; + + List supportedContexts; + + String title; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto && + other.description == description && + other.id == id && + other.methodName == methodName && + other.pluginId == pluginId && + other.schema == schema && + _deepEquality.equals(other.supportedContexts, supportedContexts) && + other.title == title; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description.hashCode) + + (id.hashCode) + + (methodName.hashCode) + + (pluginId.hashCode) + + (schema == null ? 0 : schema!.hashCode) + + (supportedContexts.hashCode) + + (title.hashCode); + + @override + String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; + + Map toJson() { + final json = {}; + json[r'description'] = this.description; + json[r'id'] = this.id; + json[r'methodName'] = this.methodName; + json[r'pluginId'] = this.pluginId; + if (this.schema != null) { + json[r'schema'] = this.schema; + } else { + // json[r'schema'] = null; + } + json[r'supportedContexts'] = this.supportedContexts; + json[r'title'] = this.title; + return json; + } + + /// Returns a new [PluginFilterResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginFilterResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginFilterResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginFilterResponseDto( + description: mapValueOfType(json, r'description')!, + id: mapValueOfType(json, r'id')!, + methodName: mapValueOfType(json, r'methodName')!, + pluginId: mapValueOfType(json, r'pluginId')!, + schema: mapValueOfType(json, r'schema'), + supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']), + title: mapValueOfType(json, r'title')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginFilterResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginFilterResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'id', + 'methodName', + 'pluginId', + 'schema', + 'supportedContexts', + 'title', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_response_dto.dart b/mobile/openapi/lib/model/plugin_response_dto.dart new file mode 100644 index 0000000000..afa6f3e1ab --- /dev/null +++ b/mobile/openapi/lib/model/plugin_response_dto.dart @@ -0,0 +1,171 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PluginResponseDto { + /// Returns a new [PluginResponseDto] instance. + PluginResponseDto({ + this.actions = const [], + required this.author, + required this.createdAt, + required this.description, + this.filters = const [], + required this.id, + required this.name, + required this.title, + required this.updatedAt, + required this.version, + }); + + List actions; + + String author; + + String createdAt; + + String description; + + List filters; + + String id; + + String name; + + String title; + + String updatedAt; + + String version; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto && + _deepEquality.equals(other.actions, actions) && + other.author == author && + other.createdAt == createdAt && + other.description == description && + _deepEquality.equals(other.filters, filters) && + other.id == id && + other.name == name && + other.title == title && + other.updatedAt == updatedAt && + other.version == version; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (author.hashCode) + + (createdAt.hashCode) + + (description.hashCode) + + (filters.hashCode) + + (id.hashCode) + + (name.hashCode) + + (title.hashCode) + + (updatedAt.hashCode) + + (version.hashCode); + + @override + String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + json[r'author'] = this.author; + json[r'createdAt'] = this.createdAt; + json[r'description'] = this.description; + json[r'filters'] = this.filters; + json[r'id'] = this.id; + json[r'name'] = this.name; + json[r'title'] = this.title; + json[r'updatedAt'] = this.updatedAt; + json[r'version'] = this.version; + return json; + } + + /// Returns a new [PluginResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginResponseDto( + actions: PluginActionResponseDto.listFromJson(json[r'actions']), + author: mapValueOfType(json, r'author')!, + createdAt: mapValueOfType(json, r'createdAt')!, + description: mapValueOfType(json, r'description')!, + filters: PluginFilterResponseDto.listFromJson(json[r'filters']), + id: mapValueOfType(json, r'id')!, + name: mapValueOfType(json, r'name')!, + title: mapValueOfType(json, r'title')!, + updatedAt: mapValueOfType(json, r'updatedAt')!, + version: mapValueOfType(json, r'version')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'author', + 'createdAt', + 'description', + 'filters', + 'id', + 'name', + 'title', + 'updatedAt', + 'version', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_trigger_type.dart b/mobile/openapi/lib/model/plugin_trigger_type.dart new file mode 100644 index 0000000000..b200f1b9e6 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_trigger_type.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PluginTriggerType { + /// Instantiate a new enum with the provided [value]. + const PluginTriggerType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetCreate = PluginTriggerType._(r'AssetCreate'); + static const personRecognized = PluginTriggerType._(r'PersonRecognized'); + + /// List of all possible values in this [enum][PluginTriggerType]. + static const values = [ + assetCreate, + personRecognized, + ]; + + static PluginTriggerType? fromJson(dynamic value) => PluginTriggerTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginTriggerType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [PluginTriggerType] to String, +/// and [decode] dynamic data back to [PluginTriggerType]. +class PluginTriggerTypeTypeTransformer { + factory PluginTriggerTypeTypeTransformer() => _instance ??= const PluginTriggerTypeTypeTransformer._(); + + const PluginTriggerTypeTypeTransformer._(); + + String encode(PluginTriggerType data) => data.value; + + /// Decodes a [dynamic value][data] to a PluginTriggerType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + PluginTriggerType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetCreate': return PluginTriggerType.assetCreate; + case r'PersonRecognized': return PluginTriggerType.personRecognized; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PluginTriggerTypeTypeTransformer] instance. + static PluginTriggerTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/queue_name.dart b/mobile/openapi/lib/model/queue_name.dart index 7b8214e202..bcc4159fce 100644 --- a/mobile/openapi/lib/model/queue_name.dart +++ b/mobile/openapi/lib/model/queue_name.dart @@ -39,6 +39,7 @@ class QueueName { static const notifications = QueueName._(r'notifications'); static const backupDatabase = QueueName._(r'backupDatabase'); static const ocr = QueueName._(r'ocr'); + static const workflow = QueueName._(r'workflow'); /// List of all possible values in this [enum][QueueName]. static const values = [ @@ -58,6 +59,7 @@ class QueueName { notifications, backupDatabase, ocr, + workflow, ]; static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value); @@ -112,6 +114,7 @@ class QueueNameTypeTransformer { case r'notifications': return QueueName.notifications; case r'backupDatabase': return QueueName.backupDatabase; case r'ocr': return QueueName.ocr; + case r'workflow': return QueueName.workflow; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/queues_response_dto.dart b/mobile/openapi/lib/model/queues_response_dto.dart index b20f8c3c09..be40a56fb1 100644 --- a/mobile/openapi/lib/model/queues_response_dto.dart +++ b/mobile/openapi/lib/model/queues_response_dto.dart @@ -29,6 +29,7 @@ class QueuesResponseDto { required this.storageTemplateMigration, required this.thumbnailGeneration, required this.videoConversion, + required this.workflow, }); QueueResponseDto backgroundTask; @@ -63,6 +64,8 @@ class QueuesResponseDto { QueueResponseDto videoConversion; + QueueResponseDto workflow; + @override bool operator ==(Object other) => identical(this, other) || other is QueuesResponseDto && other.backgroundTask == backgroundTask && @@ -80,7 +83,8 @@ class QueuesResponseDto { other.smartSearch == smartSearch && other.storageTemplateMigration == storageTemplateMigration && other.thumbnailGeneration == thumbnailGeneration && - other.videoConversion == videoConversion; + other.videoConversion == videoConversion && + other.workflow == workflow; @override int get hashCode => @@ -100,10 +104,11 @@ class QueuesResponseDto { (smartSearch.hashCode) + (storageTemplateMigration.hashCode) + (thumbnailGeneration.hashCode) + - (videoConversion.hashCode); + (videoConversion.hashCode) + + (workflow.hashCode); @override - String toString() => 'QueuesResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'QueuesResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; @@ -123,6 +128,7 @@ class QueuesResponseDto { json[r'storageTemplateMigration'] = this.storageTemplateMigration; json[r'thumbnailGeneration'] = this.thumbnailGeneration; json[r'videoConversion'] = this.videoConversion; + json[r'workflow'] = this.workflow; return json; } @@ -151,6 +157,7 @@ class QueuesResponseDto { storageTemplateMigration: QueueResponseDto.fromJson(json[r'storageTemplateMigration'])!, thumbnailGeneration: QueueResponseDto.fromJson(json[r'thumbnailGeneration'])!, videoConversion: QueueResponseDto.fromJson(json[r'videoConversion'])!, + workflow: QueueResponseDto.fromJson(json[r'workflow'])!, ); } return null; @@ -214,6 +221,7 @@ class QueuesResponseDto { 'storageTemplateMigration', 'thumbnailGeneration', 'videoConversion', + 'workflow', }; } diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 3eeb9c7d3b..461420b3e3 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -25,6 +25,7 @@ class SystemConfigJobDto { required this.smartSearch, required this.thumbnailGeneration, required this.videoConversion, + required this.workflow, }); JobSettingsDto backgroundTask; @@ -51,6 +52,8 @@ class SystemConfigJobDto { JobSettingsDto videoConversion; + JobSettingsDto workflow; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto && other.backgroundTask == backgroundTask && @@ -64,7 +67,8 @@ class SystemConfigJobDto { other.sidecar == sidecar && other.smartSearch == smartSearch && other.thumbnailGeneration == thumbnailGeneration && - other.videoConversion == videoConversion; + other.videoConversion == videoConversion && + other.workflow == workflow; @override int get hashCode => @@ -80,10 +84,11 @@ class SystemConfigJobDto { (sidecar.hashCode) + (smartSearch.hashCode) + (thumbnailGeneration.hashCode) + - (videoConversion.hashCode); + (videoConversion.hashCode) + + (workflow.hashCode); @override - String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; @@ -99,6 +104,7 @@ class SystemConfigJobDto { json[r'smartSearch'] = this.smartSearch; json[r'thumbnailGeneration'] = this.thumbnailGeneration; json[r'videoConversion'] = this.videoConversion; + json[r'workflow'] = this.workflow; return json; } @@ -123,6 +129,7 @@ class SystemConfigJobDto { smartSearch: JobSettingsDto.fromJson(json[r'smartSearch'])!, thumbnailGeneration: JobSettingsDto.fromJson(json[r'thumbnailGeneration'])!, videoConversion: JobSettingsDto.fromJson(json[r'videoConversion'])!, + workflow: JobSettingsDto.fromJson(json[r'workflow'])!, ); } return null; @@ -182,6 +189,7 @@ class SystemConfigJobDto { 'smartSearch', 'thumbnailGeneration', 'videoConversion', + 'workflow', }; } diff --git a/mobile/openapi/lib/model/workflow_action_item_dto.dart b/mobile/openapi/lib/model/workflow_action_item_dto.dart new file mode 100644 index 0000000000..ee0b30216d --- /dev/null +++ b/mobile/openapi/lib/model/workflow_action_item_dto.dart @@ -0,0 +1,116 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowActionItemDto { + /// Returns a new [WorkflowActionItemDto] instance. + WorkflowActionItemDto({ + this.actionConfig, + required this.actionId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? actionConfig; + + String actionId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto && + other.actionConfig == actionConfig && + other.actionId == actionId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actionConfig == null ? 0 : actionConfig!.hashCode) + + (actionId.hashCode); + + @override + String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, actionId=$actionId]'; + + Map toJson() { + final json = {}; + if (this.actionConfig != null) { + json[r'actionConfig'] = this.actionConfig; + } else { + // json[r'actionConfig'] = null; + } + json[r'actionId'] = this.actionId; + return json; + } + + /// Returns a new [WorkflowActionItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowActionItemDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowActionItemDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowActionItemDto( + actionConfig: mapValueOfType(json, r'actionConfig'), + actionId: mapValueOfType(json, r'actionId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowActionItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowActionItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowActionItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowActionItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actionId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_action_response_dto.dart b/mobile/openapi/lib/model/workflow_action_response_dto.dart new file mode 100644 index 0000000000..6528f018c9 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_action_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowActionResponseDto { + /// Returns a new [WorkflowActionResponseDto] instance. + WorkflowActionResponseDto({ + required this.actionConfig, + required this.actionId, + required this.id, + required this.order, + required this.workflowId, + }); + + Object? actionConfig; + + String actionId; + + String id; + + num order; + + String workflowId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto && + other.actionConfig == actionConfig && + other.actionId == actionId && + other.id == id && + other.order == order && + other.workflowId == workflowId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actionConfig == null ? 0 : actionConfig!.hashCode) + + (actionId.hashCode) + + (id.hashCode) + + (order.hashCode) + + (workflowId.hashCode); + + @override + String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, actionId=$actionId, id=$id, order=$order, workflowId=$workflowId]'; + + Map toJson() { + final json = {}; + if (this.actionConfig != null) { + json[r'actionConfig'] = this.actionConfig; + } else { + // json[r'actionConfig'] = null; + } + json[r'actionId'] = this.actionId; + json[r'id'] = this.id; + json[r'order'] = this.order; + json[r'workflowId'] = this.workflowId; + return json; + } + + /// Returns a new [WorkflowActionResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowActionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowActionResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowActionResponseDto( + actionConfig: mapValueOfType(json, r'actionConfig'), + actionId: mapValueOfType(json, r'actionId')!, + id: mapValueOfType(json, r'id')!, + order: num.parse('${json[r'order']}'), + workflowId: mapValueOfType(json, r'workflowId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowActionResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowActionResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowActionResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowActionResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actionConfig', + 'actionId', + 'id', + 'order', + 'workflowId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_create_dto.dart b/mobile/openapi/lib/model/workflow_create_dto.dart new file mode 100644 index 0000000000..c6e44743ac --- /dev/null +++ b/mobile/openapi/lib/model/workflow_create_dto.dart @@ -0,0 +1,157 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowCreateDto { + /// Returns a new [WorkflowCreateDto] instance. + WorkflowCreateDto({ + this.actions = const [], + this.description, + this.enabled, + this.filters = const [], + required this.name, + required this.triggerType, + }); + + List actions; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + List filters; + + String name; + + PluginTriggerType triggerType; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto && + _deepEquality.equals(other.actions, actions) && + other.description == description && + other.enabled == enabled && + _deepEquality.equals(other.filters, filters) && + other.name == name && + other.triggerType == triggerType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (description == null ? 0 : description!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (filters.hashCode) + + (name.hashCode) + + (triggerType.hashCode); + + @override + String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'filters'] = this.filters; + json[r'name'] = this.name; + json[r'triggerType'] = this.triggerType; + return json; + } + + /// Returns a new [WorkflowCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowCreateDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowCreateDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowCreateDto( + actions: WorkflowActionItemDto.listFromJson(json[r'actions']), + description: mapValueOfType(json, r'description'), + enabled: mapValueOfType(json, r'enabled'), + filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), + name: mapValueOfType(json, r'name')!, + triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'filters', + 'name', + 'triggerType', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_filter_item_dto.dart b/mobile/openapi/lib/model/workflow_filter_item_dto.dart new file mode 100644 index 0000000000..5b78585c3d --- /dev/null +++ b/mobile/openapi/lib/model/workflow_filter_item_dto.dart @@ -0,0 +1,116 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowFilterItemDto { + /// Returns a new [WorkflowFilterItemDto] instance. + WorkflowFilterItemDto({ + this.filterConfig, + required this.filterId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? filterConfig; + + String filterId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto && + other.filterConfig == filterConfig && + other.filterId == filterId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (filterConfig == null ? 0 : filterConfig!.hashCode) + + (filterId.hashCode); + + @override + String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, filterId=$filterId]'; + + Map toJson() { + final json = {}; + if (this.filterConfig != null) { + json[r'filterConfig'] = this.filterConfig; + } else { + // json[r'filterConfig'] = null; + } + json[r'filterId'] = this.filterId; + return json; + } + + /// Returns a new [WorkflowFilterItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowFilterItemDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowFilterItemDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowFilterItemDto( + filterConfig: mapValueOfType(json, r'filterConfig'), + filterId: mapValueOfType(json, r'filterId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowFilterItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowFilterItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowFilterItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowFilterItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'filterId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_filter_response_dto.dart b/mobile/openapi/lib/model/workflow_filter_response_dto.dart new file mode 100644 index 0000000000..5257c92b80 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_filter_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowFilterResponseDto { + /// Returns a new [WorkflowFilterResponseDto] instance. + WorkflowFilterResponseDto({ + required this.filterConfig, + required this.filterId, + required this.id, + required this.order, + required this.workflowId, + }); + + Object? filterConfig; + + String filterId; + + String id; + + num order; + + String workflowId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto && + other.filterConfig == filterConfig && + other.filterId == filterId && + other.id == id && + other.order == order && + other.workflowId == workflowId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (filterConfig == null ? 0 : filterConfig!.hashCode) + + (filterId.hashCode) + + (id.hashCode) + + (order.hashCode) + + (workflowId.hashCode); + + @override + String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, filterId=$filterId, id=$id, order=$order, workflowId=$workflowId]'; + + Map toJson() { + final json = {}; + if (this.filterConfig != null) { + json[r'filterConfig'] = this.filterConfig; + } else { + // json[r'filterConfig'] = null; + } + json[r'filterId'] = this.filterId; + json[r'id'] = this.id; + json[r'order'] = this.order; + json[r'workflowId'] = this.workflowId; + return json; + } + + /// Returns a new [WorkflowFilterResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowFilterResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowFilterResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowFilterResponseDto( + filterConfig: mapValueOfType(json, r'filterConfig'), + filterId: mapValueOfType(json, r'filterId')!, + id: mapValueOfType(json, r'id')!, + order: num.parse('${json[r'order']}'), + workflowId: mapValueOfType(json, r'workflowId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowFilterResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowFilterResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowFilterResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowFilterResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'filterConfig', + 'filterId', + 'id', + 'order', + 'workflowId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_response_dto.dart b/mobile/openapi/lib/model/workflow_response_dto.dart new file mode 100644 index 0000000000..5132e7cb73 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_response_dto.dart @@ -0,0 +1,241 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowResponseDto { + /// Returns a new [WorkflowResponseDto] instance. + WorkflowResponseDto({ + this.actions = const [], + required this.createdAt, + required this.description, + required this.enabled, + this.filters = const [], + required this.id, + required this.name, + required this.ownerId, + required this.triggerType, + }); + + List actions; + + String createdAt; + + String description; + + bool enabled; + + List filters; + + String id; + + String? name; + + String ownerId; + + WorkflowResponseDtoTriggerTypeEnum triggerType; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto && + _deepEquality.equals(other.actions, actions) && + other.createdAt == createdAt && + other.description == description && + other.enabled == enabled && + _deepEquality.equals(other.filters, filters) && + other.id == id && + other.name == name && + other.ownerId == ownerId && + other.triggerType == triggerType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (createdAt.hashCode) + + (description.hashCode) + + (enabled.hashCode) + + (filters.hashCode) + + (id.hashCode) + + (name == null ? 0 : name!.hashCode) + + (ownerId.hashCode) + + (triggerType.hashCode); + + @override + String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + json[r'createdAt'] = this.createdAt; + json[r'description'] = this.description; + json[r'enabled'] = this.enabled; + json[r'filters'] = this.filters; + json[r'id'] = this.id; + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + json[r'ownerId'] = this.ownerId; + json[r'triggerType'] = this.triggerType; + return json; + } + + /// Returns a new [WorkflowResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowResponseDto( + actions: WorkflowActionResponseDto.listFromJson(json[r'actions']), + createdAt: mapValueOfType(json, r'createdAt')!, + description: mapValueOfType(json, r'description')!, + enabled: mapValueOfType(json, r'enabled')!, + filters: WorkflowFilterResponseDto.listFromJson(json[r'filters']), + id: mapValueOfType(json, r'id')!, + name: mapValueOfType(json, r'name'), + ownerId: mapValueOfType(json, r'ownerId')!, + triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'createdAt', + 'description', + 'enabled', + 'filters', + 'id', + 'name', + 'ownerId', + 'triggerType', + }; +} + + +class WorkflowResponseDtoTriggerTypeEnum { + /// Instantiate a new enum with the provided [value]. + const WorkflowResponseDtoTriggerTypeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate'); + static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized'); + + /// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum]. + static const values = [ + assetCreate, + personRecognized, + ]; + + static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String, +/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum]. +class WorkflowResponseDtoTriggerTypeEnumTypeTransformer { + factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._(); + + const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._(); + + String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate; + case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance. + static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/workflow_update_dto.dart b/mobile/openapi/lib/model/workflow_update_dto.dart new file mode 100644 index 0000000000..b36a396dc6 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_update_dto.dart @@ -0,0 +1,156 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowUpdateDto { + /// Returns a new [WorkflowUpdateDto] instance. + WorkflowUpdateDto({ + this.actions = const [], + this.description, + this.enabled, + this.filters = const [], + this.name, + }); + + List actions; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + List filters; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? name; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto && + _deepEquality.equals(other.actions, actions) && + other.description == description && + other.enabled == enabled && + _deepEquality.equals(other.filters, filters) && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (description == null ? 0 : description!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (filters.hashCode) + + (name == null ? 0 : name!.hashCode); + + @override + String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'filters'] = this.filters; + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + return json; + } + + /// Returns a new [WorkflowUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowUpdateDto( + actions: WorkflowActionItemDto.listFromJson(json[r'actions']), + description: mapValueOfType(json, r'description'), + enabled: mapValueOfType(json, r'enabled'), + filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), + name: mapValueOfType(json, r'name'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowUpdateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7ae48eaf8b..d42aa0baa1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7865,6 +7865,111 @@ "x-immich-state": "Stable" } }, + "/plugins": { + "get": { + "description": "Retrieve a list of plugins available to the authenticated user.", + "operationId": "getPlugins", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PluginResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all plugins", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "plugin.read", + "x-immich-state": "Alpha" + } + }, + "/plugins/{id}": { + "get": { + "description": "Retrieve information about a specific plugin by its ID.", + "operationId": "getPlugin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a plugin", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "plugin.read", + "x-immich-state": "Alpha" + } + }, "/search/cities": { "get": { "description": "Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.", @@ -13485,6 +13590,276 @@ ], "x-immich-state": "Stable" } + }, + "/workflows": { + "get": { + "description": "Retrieve a list of workflows available to the authenticated user.", + "operationId": "getWorkflows", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all workflows", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.read", + "x-immich-state": "Alpha" + }, + "post": { + "description": "Create a new workflow, the workflow can also be created with empty filters and actions.", + "operationId": "createWorkflow", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.create", + "x-immich-state": "Alpha" + } + }, + "/workflows/{id}": { + "delete": { + "description": "Delete a workflow by its ID.", + "operationId": "deleteWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Retrieve information about a specific workflow by its ID.", + "operationId": "getWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.read", + "x-immich-state": "Alpha" + }, + "put": { + "description": "Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.", + "operationId": "updateWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.update", + "x-immich-state": "Alpha" + } } }, "info": { @@ -13566,6 +13941,10 @@ "name": "People", "description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job." }, + { + "name": "Plugins", + "description": "A plugin is an installed module that makes filters and actions available for the workflow feature." + }, { "name": "Search", "description": "Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting." @@ -13621,6 +14000,10 @@ { "name": "Views", "description": "Endpoints for specialized views, such as the folder view." + }, + { + "name": "Workflows", + "description": "A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution." } ], "servers": [ @@ -17022,6 +17405,10 @@ "pinCode.create", "pinCode.update", "pinCode.delete", + "plugin.create", + "plugin.read", + "plugin.update", + "plugin.delete", "server.about", "server.apkLinks", "server.storage", @@ -17071,6 +17458,10 @@ "userProfileImage.read", "userProfileImage.update", "userProfileImage.delete", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", "adminUser.create", "adminUser.read", "adminUser.update", @@ -17367,6 +17758,152 @@ ], "type": "object" }, + "PluginActionResponseDto": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "methodName": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "schema": { + "nullable": true, + "type": "object" + }, + "supportedContexts": { + "items": { + "$ref": "#/components/schemas/PluginContext" + }, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "methodName", + "pluginId", + "schema", + "supportedContexts", + "title" + ], + "type": "object" + }, + "PluginContext": { + "enum": [ + "asset", + "album", + "person" + ], + "type": "string" + }, + "PluginFilterResponseDto": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "methodName": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "schema": { + "nullable": true, + "type": "object" + }, + "supportedContexts": { + "items": { + "$ref": "#/components/schemas/PluginContext" + }, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "methodName", + "pluginId", + "schema", + "supportedContexts", + "title" + ], + "type": "object" + }, + "PluginResponseDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/PluginActionResponseDto" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/PluginFilterResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "actions", + "author", + "createdAt", + "description", + "filters", + "id", + "name", + "title", + "updatedAt", + "version" + ], + "type": "object" + }, + "PluginTriggerType": { + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { @@ -17438,7 +17975,8 @@ "library", "notifications", "backupDatabase", - "ocr" + "ocr", + "workflow" ], "type": "string" }, @@ -17552,6 +18090,9 @@ }, "videoConversion": { "$ref": "#/components/schemas/QueueResponseDto" + }, + "workflow": { + "$ref": "#/components/schemas/QueueResponseDto" } }, "required": [ @@ -17570,7 +18111,8 @@ "smartSearch", "storageTemplateMigration", "thumbnailGeneration", - "videoConversion" + "videoConversion", + "workflow" ], "type": "object" }, @@ -20420,6 +20962,9 @@ }, "videoConversion": { "$ref": "#/components/schemas/JobSettingsDto" + }, + "workflow": { + "$ref": "#/components/schemas/JobSettingsDto" } }, "required": [ @@ -20434,7 +20979,8 @@ "sidecar", "smartSearch", "thumbnailGeneration", - "videoConversion" + "videoConversion", + "workflow" ], "type": "object" }, @@ -21999,6 +22545,211 @@ "webm" ], "type": "string" + }, + "WorkflowActionItemDto": { + "properties": { + "actionConfig": { + "type": "object" + }, + "actionId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "actionId" + ], + "type": "object" + }, + "WorkflowActionResponseDto": { + "properties": { + "actionConfig": { + "nullable": true, + "type": "object" + }, + "actionId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order": { + "type": "number" + }, + "workflowId": { + "type": "string" + } + }, + "required": [ + "actionConfig", + "actionId", + "id", + "order", + "workflowId" + ], + "type": "object" + }, + "WorkflowCreateDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionItemDto" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterItemDto" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "triggerType": { + "allOf": [ + { + "$ref": "#/components/schemas/PluginTriggerType" + } + ] + } + }, + "required": [ + "actions", + "filters", + "name", + "triggerType" + ], + "type": "object" + }, + "WorkflowFilterItemDto": { + "properties": { + "filterConfig": { + "type": "object" + }, + "filterId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "filterId" + ], + "type": "object" + }, + "WorkflowFilterResponseDto": { + "properties": { + "filterConfig": { + "nullable": true, + "type": "object" + }, + "filterId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order": { + "type": "number" + }, + "workflowId": { + "type": "string" + } + }, + "required": [ + "filterConfig", + "filterId", + "id", + "order", + "workflowId" + ], + "type": "object" + }, + "WorkflowResponseDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionResponseDto" + }, + "type": "array" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "name": { + "nullable": true, + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "triggerType": { + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + } + }, + "required": [ + "actions", + "createdAt", + "description", + "enabled", + "filters", + "id", + "name", + "ownerId", + "triggerType" + ], + "type": "object" + }, + "WorkflowUpdateDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionItemDto" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterItemDto" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "type": "object" } } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 00a6eea954..0664d26995 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -732,6 +732,7 @@ export type QueuesResponseDto = { storageTemplateMigration: QueueResponseDto; thumbnailGeneration: QueueResponseDto; videoConversion: QueueResponseDto; + workflow: QueueResponseDto; }; export type JobCreateDto = { name: ManualJobName; @@ -926,6 +927,36 @@ export type AssetFaceUpdateDto = { export type PersonStatisticsResponseDto = { assets: number; }; +export type PluginActionResponseDto = { + description: string; + id: string; + methodName: string; + pluginId: string; + schema: object | null; + supportedContexts: PluginContext[]; + title: string; +}; +export type PluginFilterResponseDto = { + description: string; + id: string; + methodName: string; + pluginId: string; + schema: object | null; + supportedContexts: PluginContext[]; + title: string; +}; +export type PluginResponseDto = { + actions: PluginActionResponseDto[]; + author: string; + createdAt: string; + description: string; + filters: PluginFilterResponseDto[]; + id: string; + name: string; + title: string; + updatedAt: string; + version: string; +}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -1411,6 +1442,7 @@ export type SystemConfigJobDto = { smartSearch: JobSettingsDto; thumbnailGeneration: JobSettingsDto; videoConversion: JobSettingsDto; + workflow: JobSettingsDto; }; export type SystemConfigLibraryScanDto = { cronExpression: string; @@ -1667,6 +1699,54 @@ export type CreateProfileImageResponseDto = { profileImagePath: string; userId: string; }; +export type WorkflowActionResponseDto = { + actionConfig: object | null; + actionId: string; + id: string; + order: number; + workflowId: string; +}; +export type WorkflowFilterResponseDto = { + filterConfig: object | null; + filterId: string; + id: string; + order: number; + workflowId: string; +}; +export type WorkflowResponseDto = { + actions: WorkflowActionResponseDto[]; + createdAt: string; + description: string; + enabled: boolean; + filters: WorkflowFilterResponseDto[]; + id: string; + name: string | null; + ownerId: string; + triggerType: TriggerType; +}; +export type WorkflowActionItemDto = { + actionConfig?: object; + actionId: string; +}; +export type WorkflowFilterItemDto = { + filterConfig?: object; + filterId: string; +}; +export type WorkflowCreateDto = { + actions: WorkflowActionItemDto[]; + description?: string; + enabled?: boolean; + filters: WorkflowFilterItemDto[]; + name: string; + triggerType: PluginTriggerType; +}; +export type WorkflowUpdateDto = { + actions?: WorkflowActionItemDto[]; + description?: string; + enabled?: boolean; + filters?: WorkflowFilterItemDto[]; + name?: string; +}; /** * List all activities */ @@ -3510,6 +3590,30 @@ export function getPersonThumbnail({ id }: { ...opts })); } +/** + * List all plugins + */ +export function getPlugins(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginResponseDto[]; + }>("/plugins", { + ...opts + })); +} +/** + * Retrieve a plugin + */ +export function getPlugin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginResponseDto; + }>(`/plugins/${encodeURIComponent(id)}`, { + ...opts + })); +} /** * Retrieve assets by city */ @@ -4824,6 +4928,72 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * List all workflows + */ +export function getWorkflows(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto[]; + }>("/workflows", { + ...opts + })); +} +/** + * Create a workflow + */ +export function createWorkflow({ workflowCreateDto }: { + workflowCreateDto: WorkflowCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: WorkflowResponseDto; + }>("/workflows", oazapfts.json({ + ...opts, + method: "POST", + body: workflowCreateDto + }))); +} +/** + * Delete a workflow + */ +export function deleteWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/workflows/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve a workflow + */ +export function getWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Update a workflow + */ +export function updateWorkflow({ id, workflowUpdateDto }: { + id: string; + workflowUpdateDto: WorkflowUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: workflowUpdateDto + }))); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -4976,6 +5146,10 @@ export enum Permission { PinCodeCreate = "pinCode.create", PinCodeUpdate = "pinCode.update", PinCodeDelete = "pinCode.delete", + PluginCreate = "plugin.create", + PluginRead = "plugin.read", + PluginUpdate = "plugin.update", + PluginDelete = "plugin.delete", ServerAbout = "server.about", ServerApkLinks = "server.apkLinks", ServerStorage = "server.storage", @@ -5025,6 +5199,10 @@ export enum Permission { UserProfileImageRead = "userProfileImage.read", UserProfileImageUpdate = "userProfileImage.update", UserProfileImageDelete = "userProfileImage.delete", + WorkflowCreate = "workflow.create", + WorkflowRead = "workflow.read", + WorkflowUpdate = "workflow.update", + WorkflowDelete = "workflow.delete", AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", @@ -5083,7 +5261,8 @@ export enum QueueName { Library = "library", Notifications = "notifications", BackupDatabase = "backupDatabase", - Ocr = "ocr" + Ocr = "ocr", + Workflow = "workflow" } export enum QueueCommand { Start = "start", @@ -5104,6 +5283,11 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } +export enum PluginContext { + Asset = "asset", + Album = "album", + Person = "person" +} export enum SearchSuggestionType { Country = "country", State = "state", @@ -5255,3 +5439,11 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum TriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} +export enum PluginTriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 0000000000..76add878f8 --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/plugins/LICENSE b/plugins/LICENSE new file mode 100644 index 0000000000..53f0fa6953 --- /dev/null +++ b/plugins/LICENSE @@ -0,0 +1,26 @@ +Copyright 2024, The Extism Authors. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/esbuild.js b/plugins/esbuild.js new file mode 100644 index 0000000000..04cb6e85aa --- /dev/null +++ b/plugins/esbuild.js @@ -0,0 +1,12 @@ +const esbuild = require('esbuild'); + +esbuild + .build({ + entryPoints: ['src/index.ts'], + outdir: 'dist', + bundle: true, + sourcemap: true, + minify: false, // might want to use true for production build + format: 'cjs', // needs to be CJS for now + target: ['es2020'] // don't go over es2020 because quickjs doesn't support it + }) \ No newline at end of file diff --git a/plugins/manifest.json b/plugins/manifest.json new file mode 100644 index 0000000000..1172530c1e --- /dev/null +++ b/plugins/manifest.json @@ -0,0 +1,127 @@ +{ + "name": "immich-core", + "version": "2.0.0", + "title": "Immich Core", + "description": "Core workflow capabilities for Immich", + "author": "Immich Team", + + "wasm": { + "path": "dist/plugin.wasm" + }, + + "filters": [ + { + "methodName": "filterFileName", + "title": "Filter by filename", + "description": "Filter assets by filename pattern using text matching or regular expressions", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Text or regex pattern to match against filename" + }, + "matchType": { + "type": "string", + "enum": ["contains", "regex", "exact"], + "default": "contains", + "description": "Type of pattern matching to perform" + }, + "caseSensitive": { + "type": "boolean", + "default": false, + "description": "Whether matching should be case-sensitive" + } + }, + "required": ["pattern"] + } + }, + { + "methodName": "filterFileType", + "title": "Filter by file type", + "description": "Filter assets by file type", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "fileTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["IMAGE", "VIDEO"] + }, + "description": "Allowed file types" + } + }, + "required": ["fileTypes"] + } + }, + { + "methodName": "filterPerson", + "title": "Filter by person", + "description": "Filter by detected person", + "supportedContexts": ["person"], + "schema": { + "type": "object", + "properties": { + "personIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of person to match" + }, + "matchAny": { + "type": "boolean", + "default": true, + "description": "Match any name (true) or require all names (false)" + } + }, + "required": ["personIds"] + } + } + ], + + "actions": [ + { + "methodName": "actionArchive", + "title": "Archive", + "description": "Move the asset to archive", + "supportedContexts": ["asset"], + "schema": {} + }, + { + "methodName": "actionFavorite", + "title": "Favorite", + "description": "Mark the asset as favorite or unfavorite", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "favorite": { + "type": "boolean", + "default": true, + "description": "Set favorite (true) or unfavorite (false)" + } + } + } + }, + { + "methodName": "actionAddToAlbum", + "title": "Add to Album", + "description": "Add the item to a specified album", + "supportedContexts": ["asset", "person"], + "schema": { + "type": "object", + "properties": { + "albumId": { + "type": "string", + "description": "Target album ID" + } + }, + "required": ["albumId"] + } + } + ] +} diff --git a/plugins/mise.toml b/plugins/mise.toml new file mode 100644 index 0000000000..c1001e574b --- /dev/null +++ b/plugins/mise.toml @@ -0,0 +1,11 @@ +[tools] +"github:extism/cli" = "1.6.3" +"github:webassembly/binaryen" = "version_124" +"github:extism/js-pdk" = "1.5.1" + +[tasks.install] +run = "pnpm install --frozen-lockfile" + +[tasks.build] +depends = ["install"] +run = "pnpm run build" diff --git a/plugins/package-lock.json b/plugins/package-lock.json new file mode 100644 index 0000000000..3b0f0b34cb --- /dev/null +++ b/plugins/package-lock.json @@ -0,0 +1,443 @@ +{ + "name": "js-pdk-template", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "js-pdk-template", + "version": "1.0.0", + "license": "BSD-3-Clause", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extism/js-pdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.0.1.tgz", + "integrity": "sha512-YJWfHGeOuJnQw4V8NPNHvbSr6S8iDd2Ga6VEukwlRP7tu62ozTxIgokYw8i+rajD/16zz/gK0KYARBpm2qPAmQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/typescript": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/plugins/package.json b/plugins/package.json new file mode 100644 index 0000000000..ab6b2f8435 --- /dev/null +++ b/plugins/package.json @@ -0,0 +1,19 @@ +{ + "name": "plugins", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "scripts": { + "build": "pnpm build:tsc && pnpm build:wasm", + "build:tsc": "tsc --noEmit && node esbuild.js", + "build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" + }, + "keywords": [], + "author": "", + "license": "AGPL-3.0", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } +} diff --git a/plugins/src/index.d.ts b/plugins/src/index.d.ts new file mode 100644 index 0000000000..7f805aafe6 --- /dev/null +++ b/plugins/src/index.d.ts @@ -0,0 +1,12 @@ +declare module 'main' { + export function filterFileName(): I32; + export function actionAddToAlbum(): I32; + export function actionArchive(): I32; +} + +declare module 'extism:host' { + interface user { + updateAsset(ptr: PTR): I32; + addAssetToAlbum(ptr: PTR): I32; + } +} diff --git a/plugins/src/index.ts b/plugins/src/index.ts new file mode 100644 index 0000000000..9566c02cd8 --- /dev/null +++ b/plugins/src/index.ts @@ -0,0 +1,71 @@ +const { updateAsset, addAssetToAlbum } = Host.getFunctions(); + +function parseInput() { + return JSON.parse(Host.inputString()); +} + +function returnOutput(output: any) { + Host.outputString(JSON.stringify(output)); + return 0; +} + +export function filterFileName() { + const input = parseInput(); + const { data, config } = input; + const { pattern, matchType = 'contains', caseSensitive = false } = config; + + const fileName = data.asset.originalFileName || data.asset.fileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + + let passed = false; + + if (matchType === 'exact') { + passed = searchName === searchPattern; + } else if (matchType === 'regex') { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + passed = regex.test(fileName); + } else { + // contains + passed = searchName.includes(searchPattern); + } + + return returnOutput({ passed }); +} + +export function actionAddToAlbum() { + const input = parseInput(); + const { authToken, config, data } = input; + const { albumId } = config; + + const ptr = Memory.fromString( + JSON.stringify({ + authToken, + assetId: data.asset.id, + albumId: albumId, + }) + ); + + addAssetToAlbum(ptr.offset); + ptr.free(); + + return returnOutput({ success: true }); +} + +export function actionArchive() { + const input = parseInput(); + const { authToken, data } = input; + const ptr = Memory.fromString( + JSON.stringify({ + authToken, + id: data.asset.id, + visibility: 'archive', + }) + ); + + updateAsset(ptr.offset); + ptr.free(); + + return returnOutput({ success: true }); +} diff --git a/plugins/tsconfig.json b/plugins/tsconfig.json new file mode 100644 index 0000000000..86c9e766bf --- /dev/null +++ b/plugins/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", // Specify ECMAScript target version + "module": "commonjs", // Specify module code generation + "lib": [ + "es2020" + ], // Specify a list of library files to be included in the compilation + "types": [ + "@extism/js-pdk", + "./src/index.d.ts" + ], // Specify a list of type definition files to be included in the compilation + "strict": true, // Enable all strict type-checking options + "esModuleInterop": true, // Enables compatibility with Babel-style module imports + "skipLibCheck": true, // Skip type checking of declaration files + "allowJs": true, // Allow JavaScript files to be compiled + "noEmit": true // Do not emit outputs (no .js or .d.ts files) + }, + "include": [ + "src/**/*.ts" // Include all TypeScript files in src directory + ], + "exclude": [ + "node_modules" // Exclude the node_modules directory + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ad7fcd3c..c0e4b5ea78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,8 +299,23 @@ importers: specifier: ^5.3.3 version: 5.9.3 + plugins: + devDependencies: + '@extism/js-pdk': + specifier: ^1.0.1 + version: 1.1.1 + esbuild: + specifier: ^0.19.6 + version: 0.19.12 + typescript: + specifier: ^5.3.2 + version: 5.9.3 + server: dependencies: + '@extism/extism': + specifier: 2.0.0-rc13 + version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 version: 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1) @@ -367,6 +382,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.5) + ajv: + specifier: ^8.17.1 + version: 8.17.1 archiver: specifier: ^7.0.0 version: 7.0.1 @@ -430,6 +448,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 kysely: specifier: 0.28.2 version: 0.28.2 @@ -569,6 +590,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/lodash': specifier: ^4.14.197 version: 4.17.20 @@ -2572,6 +2596,12 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@extism/extism@2.0.0-rc13': + resolution: {integrity: sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==} + + '@extism/js-pdk@1.1.1': + resolution: {integrity: sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==} + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -4590,6 +4620,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/justified-layout@4.1.4': resolution: {integrity: sha512-q2ybP0u0NVj87oMnGZOGxY2iUN8ddr48zPOBHBdbOLpsMTA/keGj+93ou+OMCnJk0xewzlNIaVEkxM6VBD3E2w==} @@ -5364,6 +5397,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6294,6 +6330,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7717,12 +7756,22 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + just-compare@2.3.0: resolution: {integrity: sha512-6shoR7HDT+fzfL3gBahx1jZG3hWLrhPAf+l7nCwahDdT9XDtosB9kIF0ZrzUp5QY8dJWfQVr5rnsPqsbvflDzg==} justified-layout@4.1.0: resolution: {integrity: sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + kdbush@3.0.0: resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} @@ -7922,15 +7971,36 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} @@ -11043,6 +11113,9 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} + urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -14170,6 +14243,12 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 + '@extism/extism@2.0.0-rc13': {} + + '@extism/js-pdk@1.1.1': + dependencies: + urlpattern-polyfill: 8.0.2 + '@faker-js/faker@10.1.0': {} '@fig/complete-commander@3.2.0(commander@11.1.0)': @@ -16414,6 +16493,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.0 + '@types/justified-layout@4.1.4': {} '@types/keygrip@1.0.6': {} @@ -17389,6 +17473,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -18341,6 +18427,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.243: {} @@ -20155,10 +20245,34 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + just-compare@2.3.0: {} justified-layout@4.1.0: {} + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + kdbush@3.0.0: {} kdbush@4.0.2: {} @@ -20323,12 +20437,26 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.uniq@4.5.0: {} lodash@4.17.21: {} @@ -24145,6 +24273,8 @@ snapshots: punycode: 1.4.1 qs: 6.14.0 + urlpattern-polyfill@8.0.2: {} + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db33e87a0e..d5629d2323 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - e2e - open-api/typescript-sdk - server + - plugins - web - .github ignoredBuiltDependencies: diff --git a/server/Dockerfile b/server/Dockerfile index 0fc4126926..0bb7fc6be5 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -48,6 +48,24 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ pnpm --filter @immich/sdk --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned +FROM builder AS plugins + +COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise + +WORKDIR /usr/src/app +COPY ./plugins/mise.toml ./plugins/ +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml +RUN mise install --cd plugins + +COPY ./plugins ./plugins/ +# Build plugins +RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ + --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ + --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ + --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ + cd plugins && mise run build + FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb WORKDIR /usr/src/app @@ -58,6 +76,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli +COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist +COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE diff --git a/server/package.json b/server/package.json index aa6ba671a5..a252a53b8a 100644 --- a/server/package.json +++ b/server/package.json @@ -34,6 +34,7 @@ "email:dev": "email dev -p 3050 --dir src/emails" }, "dependencies": { + "@extism/extism": "2.0.0-rc13", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", @@ -56,6 +57,7 @@ "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", "@socket.io/redis-adapter": "^8.3.0", + "ajv": "^8.17.1", "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^6.0.0", @@ -77,6 +79,7 @@ "i18n-iso-countries": "^7.6.0", "ioredis": "^5.8.2", "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", "kysely": "0.28.2", "kysely-postgres-js": "^3.0.0", "lodash": "^4.17.21", @@ -124,6 +127,7 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/fluent-ffmpeg": "^2.1.21", + "@types/jsonwebtoken": "^9.0.10", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/luxon": "^3.6.2", diff --git a/server/src/config.ts b/server/src/config.ts index e81ad49621..c18acd79f8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -235,6 +235,7 @@ export const defaults = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, logging: { enabled: true, diff --git a/server/src/constants.ts b/server/src/constants.ts index ddf8bc91d1..d624557c54 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -160,6 +160,8 @@ export const endpointTags: Record = { [ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.', [ApiTag.People]: 'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.', + [ApiTag.Plugins]: + 'A plugin is an installed module that makes filters and actions available for the workflow feature.', [ApiTag.Search]: 'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.', [ApiTag.Server]: @@ -185,4 +187,6 @@ export const endpointTags: Record = { [ApiTag.Users]: 'Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more.', [ApiTag.Views]: 'Endpoints for specialized views, such as the folder view.', + [ApiTag.Workflows]: + 'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.', }; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e3661ec794..c0c0461fb3 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -18,6 +18,7 @@ import { NotificationController } from 'src/controllers/notification.controller' import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; +import { PluginController } from 'src/controllers/plugin.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; @@ -32,6 +33,7 @@ import { TrashController } from 'src/controllers/trash.controller'; import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; import { ViewController } from 'src/controllers/view.controller'; +import { WorkflowController } from 'src/controllers/workflow.controller'; export const controllers = [ ApiKeyController, @@ -54,6 +56,7 @@ export const controllers = [ OAuthController, PartnerController, PersonController, + PluginController, SearchController, ServerController, SessionController, @@ -68,4 +71,5 @@ export const controllers = [ UserAdminController, UserController, ViewController, + WorkflowController, ]; diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts new file mode 100644 index 0000000000..a0a4d14b0b --- /dev/null +++ b/server/src/controllers/plugin.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { PluginResponseDto } from 'src/dtos/plugin.dto'; +import { Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { PluginService } from 'src/services/plugin.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Plugins') +@Controller('plugins') +export class PluginController { + constructor(private service: PluginService) {} + + @Get() + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'List all plugins', + description: 'Retrieve a list of plugins available to the authenticated user.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getPlugins(): Promise { + return this.service.getAll(); + } + + @Get(':id') + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'Retrieve a plugin', + description: 'Retrieve information about a specific plugin by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getPlugin(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); + } +} diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts new file mode 100644 index 0000000000..e07b6443f4 --- /dev/null +++ b/server/src/controllers/workflow.controller.ts @@ -0,0 +1,76 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { WorkflowService } from 'src/services/workflow.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Workflows') +@Controller('workflows') +export class WorkflowController { + constructor(private service: WorkflowService) {} + + @Post() + @Authenticated({ permission: Permission.WorkflowCreate }) + @Endpoint({ + summary: 'Create a workflow', + description: 'Create a new workflow, the workflow can also be created with empty filters and actions.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Get() + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'List all workflows', + description: 'Retrieve a list of workflows available to the authenticated user.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getWorkflows(@Auth() auth: AuthDto): Promise { + return this.service.getAll(auth); + } + + @Get(':id') + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'Retrieve a workflow', + description: 'Retrieve information about a specific workflow by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.WorkflowUpdate }) + @Endpoint({ + summary: 'Update a workflow', + description: + 'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + updateWorkflow( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: WorkflowUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.WorkflowDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a workflow', + description: 'Delete a workflow by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/database.ts b/server/src/database.ts index b62cb70347..4aa69127ff 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -7,6 +7,8 @@ import { AssetVisibility, MemoryType, Permission, + PluginContext, + PluginTriggerType, SharedLinkType, SourceType, UserAvatarColor, @@ -14,7 +16,10 @@ import { } from 'src/enum'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { UserMetadataItem } from 'src/types'; +import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types'; export type AuthUser = { id: string; @@ -277,6 +282,45 @@ export type AssetFace = { updateId: string; }; +export type Plugin = Selectable; + +export type PluginFilter = Selectable & { + methodName: string; + title: string; + description: string; + supportedContexts: PluginContext[]; + schema: JSONSchema | null; +}; + +export type PluginAction = Selectable & { + methodName: string; + title: string; + description: string; + supportedContexts: PluginContext[]; + schema: JSONSchema | null; +}; + +export type Workflow = Selectable & { + triggerType: PluginTriggerType; + name: string | null; + description: string; + enabled: boolean; +}; + +export type WorkflowFilter = Selectable & { + workflowId: string; + filterId: string; + filterConfig: FilterConfig | null; + order: number; +}; + +export type WorkflowAction = Selectable & { + workflowId: string; + actionId: string; + actionConfig: ActionConfig | null; + order: number; +}; + const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; const userWithPrefixColumns = [ 'user2.id', @@ -418,4 +462,15 @@ export const columns = { 'asset_exif.state', 'asset_exif.timeZone', ], + plugin: [ + 'plugin.id as id', + 'plugin.name as name', + 'plugin.title as title', + 'plugin.description as description', + 'plugin.author as author', + 'plugin.version as version', + 'plugin.wasmPath as wasmPath', + 'plugin.createdAt as createdAt', + 'plugin.updatedAt as updatedAt', + ], } as const; diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 3543d8dae9..2a9dd8b662 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -57,6 +57,13 @@ export class EnvDto { @Type(() => Number) IMMICH_MICROSERVICES_METRICS_PORT?: number; + @ValidateBoolean({ optional: true }) + IMMICH_PLUGINS_ENABLED?: boolean; + + @Optional() + @Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' }) + IMMICH_PLUGINS_INSTALL_FOLDER?: string; + @IsInt() @Optional() @Type(() => Number) diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts new file mode 100644 index 0000000000..fcb3ad4a22 --- /dev/null +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -0,0 +1,110 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsSemVer, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; +import { PluginContext } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +class PluginManifestWasmDto { + @IsString() + @IsNotEmpty() + path!: string; +} + +class PluginManifestFilterDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @IsEnum(PluginContext, { each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +class PluginManifestActionDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +export class PluginManifestDto { + @IsString() + @IsNotEmpty() + @Matches(/^[a-z0-9-]+[a-z0-9]$/, { + message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen', + }) + name!: string; + + @IsString() + @IsNotEmpty() + @IsSemVer() + version!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsString() + @IsNotEmpty() + author!: string; + + @ValidateNested() + @Type(() => PluginManifestWasmDto) + wasm!: PluginManifestWasmDto; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestFilterDto) + @IsOptional() + filters?: PluginManifestFilterDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestActionDto) + @IsOptional() + actions?: PluginManifestActionDto[]; +} diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts new file mode 100644 index 0000000000..ce80eccd65 --- /dev/null +++ b/server/src/dtos/plugin.dto.ts @@ -0,0 +1,77 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { PluginAction, PluginFilter } from 'src/database'; +import { PluginContext } from 'src/enum'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +export class PluginResponseDto { + id!: string; + name!: string; + title!: string; + description!: string; + author!: string; + version!: string; + createdAt!: string; + updatedAt!: string; + filters!: PluginFilterResponseDto[]; + actions!: PluginActionResponseDto[]; +} + +export class PluginFilterResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginActionResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginInstallDto { + @IsString() + @IsNotEmpty() + manifestPath!: string; +} + +export type MapPlugin = { + id: string; + name: string; + title: string; + description: string; + author: string; + version: string; + wasmPath: string; + createdAt: Date; + updatedAt: Date; + filters: PluginFilter[]; + actions: PluginAction[]; +}; + +export function mapPlugin(plugin: MapPlugin): PluginResponseDto { + return { + id: plugin.id, + name: plugin.name, + title: plugin.title, + description: plugin.description, + author: plugin.author, + version: plugin.version, + createdAt: plugin.createdAt.toISOString(), + updatedAt: plugin.updatedAt.toISOString(), + filters: plugin.filters, + actions: plugin.actions, + }; +} diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts index 1492e014d9..df00c5cfc2 100644 --- a/server/src/dtos/queue.dto.ts +++ b/server/src/dtos/queue.dto.ts @@ -91,4 +91,7 @@ export class QueuesResponseDto implements Record { @ApiProperty({ type: QueueResponseDto }) [QueueName.Ocr]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Workflow]!: QueueResponseDto; } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 6d36e2cc8a..c835073c31 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -224,6 +224,12 @@ class SystemConfigJobDto implements Record @IsObject() @Type(() => JobSettingsDto) [QueueName.Notification]!: JobSettingsDto; + + @ApiProperty({ type: JobSettingsDto }) + @ValidateNested() + @IsObject() + @Type(() => JobSettingsDto) + [QueueName.Workflow]!: JobSettingsDto; } class SystemConfigLibraryScanDto { diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts new file mode 100644 index 0000000000..307440945d --- /dev/null +++ b/server/src/dtos/workflow.dto.ts @@ -0,0 +1,120 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { WorkflowAction, WorkflowFilter } from 'src/database'; +import { PluginTriggerType } from 'src/enum'; +import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; +import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation'; + +export class WorkflowFilterItemDto { + @IsUUID() + filterId!: string; + + @IsObject() + @Optional() + filterConfig?: FilterConfig; +} + +export class WorkflowActionItemDto { + @IsUUID() + actionId!: string; + + @IsObject() + @Optional() + actionConfig?: ActionConfig; +} + +export class WorkflowCreateDto { + @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' }) + triggerType!: PluginTriggerType; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + filters!: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + actions!: WorkflowActionItemDto[]; +} + +export class WorkflowUpdateDto { + @IsString() + @IsNotEmpty() + @Optional() + name?: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + @Optional() + filters?: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + @Optional() + actions?: WorkflowActionItemDto[]; +} + +export class WorkflowResponseDto { + id!: string; + ownerId!: string; + triggerType!: PluginTriggerType; + name!: string | null; + description!: string; + createdAt!: string; + enabled!: boolean; + filters!: WorkflowFilterResponseDto[]; + actions!: WorkflowActionResponseDto[]; +} + +export class WorkflowFilterResponseDto { + id!: string; + workflowId!: string; + filterId!: string; + filterConfig!: FilterConfig | null; + order!: number; +} + +export class WorkflowActionResponseDto { + id!: string; + workflowId!: string; + actionId!: string; + actionConfig!: ActionConfig | null; + order!: number; +} + +export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto { + return { + id: filter.id, + workflowId: filter.workflowId, + filterId: filter.filterId, + filterConfig: filter.filterConfig, + order: filter.order, + }; +} + +export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto { + return { + id: action.id, + workflowId: action.workflowId, + actionId: action.actionId, + actionConfig: action.actionConfig, + order: action.order, + }; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index f3814863b1..6055ee85bf 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -177,6 +177,11 @@ export enum Permission { PinCodeUpdate = 'pinCode.update', PinCodeDelete = 'pinCode.delete', + PluginCreate = 'plugin.create', + PluginRead = 'plugin.read', + PluginUpdate = 'plugin.update', + PluginDelete = 'plugin.delete', + ServerAbout = 'server.about', ServerApkLinks = 'server.apkLinks', ServerStorage = 'server.storage', @@ -240,6 +245,11 @@ export enum Permission { UserProfileImageUpdate = 'userProfileImage.update', UserProfileImageDelete = 'userProfileImage.delete', + WorkflowCreate = 'workflow.create', + WorkflowRead = 'workflow.read', + WorkflowUpdate = 'workflow.update', + WorkflowDelete = 'workflow.delete', + AdminUserCreate = 'adminUser.create', AdminUserRead = 'adminUser.read', AdminUserUpdate = 'adminUser.update', @@ -525,6 +535,7 @@ export enum QueueName { Notification = 'notifications', BackupDatabase = 'backupDatabase', Ocr = 'ocr', + Workflow = 'workflow', } export enum JobName { @@ -601,6 +612,9 @@ export enum JobName { // OCR OcrQueueAll = 'OcrQueueAll', Ocr = 'Ocr', + + // Workflow + WorkflowRun = 'WorkflowRun', } export enum QueueCommand { @@ -793,6 +807,7 @@ export enum ApiTag { NotificationsAdmin = 'Notifications (admin)', Partners = 'Partners', People = 'People', + Plugins = 'Plugins', Search = 'Search', Server = 'Server', Sessions = 'Sessions', @@ -807,4 +822,16 @@ export enum ApiTag { UsersAdmin = 'Users (admin)', Users = 'Users', Views = 'Views', + Workflows = 'Workflows', +} + +export enum PluginContext { + Asset = 'asset', + Album = 'album', + Person = 'person', +} + +export enum PluginTriggerType { + AssetCreate = 'AssetCreate', + PersonRecognized = 'PersonRecognized', } diff --git a/server/src/plugins.ts b/server/src/plugins.ts new file mode 100644 index 0000000000..0c69483696 --- /dev/null +++ b/server/src/plugins.ts @@ -0,0 +1,37 @@ +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; + +export type PluginTrigger = { + name: string; + type: PluginTriggerType; + description: string; + context: PluginContext; + schema: JSONSchema | null; +}; + +export const pluginTriggers: PluginTrigger[] = [ + { + name: 'Asset Uploaded', + type: PluginTriggerType.AssetCreate, + description: 'Triggered when a new asset is uploaded', + context: PluginContext.Asset, + schema: { + type: 'object', + properties: { + assetType: { + type: 'string', + description: 'Type of the asset', + default: 'ALL', + enum: ['Image', 'Video', 'All'], + }, + }, + }, + }, + { + name: 'Person Recognized', + type: PluginTriggerType.PersonRecognized, + description: 'Triggered when a person is detected in an asset', + context: PluginContext.Person, + schema: null, + }, +]; diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 9ce6d845da..1239260dce 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -243,3 +243,12 @@ from where "partner"."sharedById" in ($1) and "partner"."sharedWithId" = $2 + +-- AccessRepository.workflow.checkOwnerAccess +select + "workflow"."id" +from + "workflow" +where + "workflow"."id" in ($1) + and "workflow"."ownerId" = $2 diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql new file mode 100644 index 0000000000..82c203dafd --- /dev/null +++ b/server/src/queries/plugin.repository.sql @@ -0,0 +1,159 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- PluginRepository.getPlugin +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +where + "plugin"."id" = $1 + +-- PluginRepository.getPluginByName +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +where + "plugin"."name" = $1 + +-- PluginRepository.getAllPlugins +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +order by + "plugin"."name" + +-- PluginRepository.getFilter +select + * +from + "plugin_filter" +where + "id" = $1 + +-- PluginRepository.getFiltersByPlugin +select + * +from + "plugin_filter" +where + "pluginId" = $1 + +-- PluginRepository.getAction +select + * +from + "plugin_action" +where + "id" = $1 + +-- PluginRepository.getActionsByPlugin +select + * +from + "plugin_action" +where + "pluginId" = $1 diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql new file mode 100644 index 0000000000..3797c5bb06 --- /dev/null +++ b/server/src/queries/workflow.repository.sql @@ -0,0 +1,68 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- WorkflowRepository.getWorkflow +select + * +from + "workflow" +where + "id" = $1 + +-- WorkflowRepository.getWorkflowsByOwner +select + * +from + "workflow" +where + "ownerId" = $1 +order by + "name" + +-- WorkflowRepository.getWorkflowsByTrigger +select + * +from + "workflow" +where + "triggerType" = $1 + and "enabled" = $2 + +-- WorkflowRepository.getWorkflowByOwnerAndTrigger +select + * +from + "workflow" +where + "ownerId" = $1 + and "triggerType" = $2 + and "enabled" = $3 + +-- WorkflowRepository.deleteWorkflow +delete from "workflow" +where + "id" = $1 + +-- WorkflowRepository.getFilters +select + * +from + "workflow_filter" +where + "workflowId" = $1 +order by + "order" asc + +-- WorkflowRepository.deleteFiltersByWorkflow +delete from "workflow_filter" +where + "workflowId" = $1 + +-- WorkflowRepository.getActions +select + * +from + "workflow_action" +where + "workflowId" = $1 +order by + "order" asc diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index a801c046aa..533e74a311 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -462,6 +462,26 @@ class TagAccess { } } +class WorkflowAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, workflowIds: Set) { + if (workflowIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('workflow') + .select('workflow.id') + .where('workflow.id', 'in', [...workflowIds]) + .where('workflow.ownerId', '=', userId) + .execute() + .then((workflows) => new Set(workflows.map((workflow) => workflow.id))); + } +} + @Injectable() export class AccessRepository { activity: ActivityAccess; @@ -476,6 +496,7 @@ export class AccessRepository { stack: StackAccess; tag: TagAccess; timeline: TimelineAccess; + workflow: WorkflowAccess; constructor(@InjectKysely() db: Kysely) { this.activity = new ActivityAccess(db); @@ -490,5 +511,6 @@ export class AccessRepository { this.stack = new StackAccess(db); this.tag = new TagAccess(db); this.timeline = new TimelineAccess(db); + this.workflow = new WorkflowAccess(db); } } diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d5c279099c..05d4bd2ac3 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -85,6 +85,7 @@ export interface EnvData { root: string; indexHtml: string; }; + corePlugin: string; }; redis: RedisOptions; @@ -102,6 +103,11 @@ export interface EnvData { workers: ImmichWorker[]; + plugins: { + enabled: boolean; + installFolder?: string; + }; + noColor: boolean; nodeVersion?: string; } @@ -304,6 +310,7 @@ const getEnv = (): EnvData => { root: folders.web, indexHtml: join(folders.web, 'index.html'), }, + corePlugin: join(buildFolder, 'corePlugin'), }, storage: { @@ -319,6 +326,11 @@ const getEnv = (): EnvData => { workers, + plugins: { + enabled: !!dto.IMMICH_PLUGINS_ENABLED, + installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER, + }, + noColor: !!dto.NO_COLOR, }; }; diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index c3136db456..bcd791ade2 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; +import jwt from 'jsonwebtoken'; import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -57,4 +58,12 @@ export class CryptoRepository { randomBytesAsText(bytes: number) { return randomBytes(bytes).toString('base64').replaceAll(/\W/g, ''); } + + signJwt(payload: string | object | Buffer, secret: string, options?: jwt.SignOptions): string { + return jwt.sign(payload, secret, { algorithm: 'HS256', ...options }); + } + + verifyJwt(token: string, secret: string): T { + return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T; + } } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index c3e6cd20cf..80d411c5ae 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -4,6 +4,7 @@ import { ClassConstructor } from 'class-transformer'; import _ from 'lodash'; import { Socket } from 'socket.io'; import { SystemConfig } from 'src/config'; +import { Asset } from 'src/database'; import { EventConfig } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; @@ -41,6 +42,7 @@ type EventMap = { AlbumInvite: [{ id: string; userId: string }]; // asset events + AssetCreate: [{ asset: Asset }]; AssetTag: [{ assetId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index cf65cfcb2a..c69536a327 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -28,6 +28,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -46,6 +47,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; export const repositories = [ AccessRepository, @@ -78,6 +80,7 @@ export const repositories = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, SessionRepository, @@ -96,4 +99,5 @@ export const repositories = [ ViewRepository, VersionHistoryRepository, WebsocketRepository, + WorkflowRepository, ]; diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts new file mode 100644 index 0000000000..6217237947 --- /dev/null +++ b/server/src/repositories/plugin.repository.ts @@ -0,0 +1,176 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { InjectKysely } from 'nestjs-kysely'; +import { readdir } from 'node:fs/promises'; +import { columns } from 'src/database'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class PluginRepository { + constructor(@InjectKysely() private db: Kysely) {} + + /** + * Loads a plugin from a validated manifest file in a transaction. + * This ensures all plugin, filter, and action operations are atomic. + * @param manifest The validated plugin manifest + * @param basePath The base directory path where the plugin is located + */ + async loadPlugin(manifest: PluginManifestDto, basePath: string) { + return this.db.transaction().execute(async (tx) => { + // Upsert the plugin + const plugin = await tx + .insertInto('plugin') + .values({ + name: manifest.name, + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmPath: `${basePath}/${manifest.wasm.path}`, + }) + .onConflict((oc) => + oc.column('name').doUpdateSet({ + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmPath: `${basePath}/${manifest.wasm.path}`, + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + + const filters = manifest.filters + ? await tx + .insertInto('plugin_filter') + .values( + manifest.filters.map((filter) => ({ + pluginId: plugin.id, + methodName: filter.methodName, + title: filter.title, + description: filter.description, + supportedContexts: filter.supportedContexts, + schema: filter.schema, + })), + ) + .onConflict((oc) => + oc.column('methodName').doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + supportedContexts: eb.ref('excluded.supportedContexts'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + const actions = manifest.actions + ? await tx + .insertInto('plugin_action') + .values( + manifest.actions.map((action) => ({ + pluginId: plugin.id, + methodName: action.methodName, + title: action.title, + description: action.description, + supportedContexts: action.supportedContexts, + schema: action.schema, + })), + ) + .onConflict((oc) => + oc.column('methodName').doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + supportedContexts: eb.ref('excluded.supportedContexts'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + return { plugin, filters, actions }; + }); + } + + async readDirectory(path: string) { + return readdir(path, { withFileTypes: true }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getPlugin(id: string) { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .where('plugin.id', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.STRING] }) + getPluginByName(name: string) { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .where('plugin.name', '=', name) + .executeTakeFirst(); + } + + @GenerateSql() + getAllPlugins() { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .orderBy('plugin.name') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFilter(id: string) { + return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFiltersByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getAction(id: string) { + return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getActionsByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute(); + } +} diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 50f44d9f67..e901273b57 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -113,6 +113,10 @@ export class StorageRepository { } } + async readTextFile(filepath: string): Promise { + return fs.readFile(filepath, 'utf8'); + } + async checkFileExists(filepath: string, mode = constants.F_OK): Promise { try { await fs.access(filepath, mode); diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts new file mode 100644 index 0000000000..4ae657cfbf --- /dev/null +++ b/server/src/repositories/workflow.repository.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { PluginTriggerType } from 'src/enum'; +import { DB } from 'src/schema'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Injectable() +export class WorkflowRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflow(id: string) { + return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflowsByOwner(ownerId: string) { + return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute(); + } + + @GenerateSql({ params: [PluginTriggerType.AssetCreate] }) + getWorkflowsByTrigger(type: PluginTriggerType) { + return this.db + .selectFrom('workflow') + .selectAll() + .where('triggerType', '=', type) + .where('enabled', '=', true) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] }) + getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) { + return this.db + .selectFrom('workflow') + .selectAll() + .where('ownerId', '=', ownerId) + .where('triggerType', '=', type) + .where('enabled', '=', true) + .execute(); + } + + async createWorkflow( + workflow: Insertable, + filters: Insertable[], + actions: Insertable[], + ) { + return await this.db.transaction().execute(async (tx) => { + const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow(); + + if (filters.length > 0) { + const newFilters = filters.map((filter) => ({ + ...filter, + workflowId: createdWorkflow.id, + })); + + await tx.insertInto('workflow_filter').values(newFilters).execute(); + } + + if (actions.length > 0) { + const newActions = actions.map((action) => ({ + ...action, + workflowId: createdWorkflow.id, + })); + await tx.insertInto('workflow_action').values(newActions).execute(); + } + + return createdWorkflow; + }); + } + + async updateWorkflow( + id: string, + workflow: Updateable, + filters: Insertable[] | undefined, + actions: Insertable[] | undefined, + ) { + return await this.db.transaction().execute(async (trx) => { + if (Object.keys(workflow).length > 0) { + await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute(); + } + + if (filters !== undefined) { + await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute(); + if (filters.length > 0) { + const filtersWithWorkflowId = filters.map((filter) => ({ + ...filter, + workflowId: id, + })); + await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute(); + } + } + + if (actions !== undefined) { + await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute(); + if (actions.length > 0) { + const actionsWithWorkflowId = actions.map((action) => ({ + ...action, + workflowId: id, + })); + await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute(); + } + } + + return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow(); + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteWorkflow(id: string) { + await this.db.deleteFrom('workflow').where('id', '=', id).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFilters(workflowId: string) { + return this.db + .selectFrom('workflow_filter') + .selectAll() + .where('workflowId', '=', workflowId) + .orderBy('order', 'asc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteFiltersByWorkflow(workflowId: string) { + await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getActions(workflowId: string) { + return this.db + .selectFrom('workflow_action') + .selectAll() + .where('workflowId', '=', workflowId) + .orderBy('order', 'asc') + .execute(); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 7f4bdbeed3..9e206826e6 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -53,6 +53,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; +import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -69,6 +70,7 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { Database, Extensions, Generated, Int8 } from 'src/sql-tools'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @@ -125,6 +127,12 @@ export class ImmichDatabase { UserMetadataAuditTable, UserTable, VersionHistoryTable, + PluginTable, + PluginFilterTable, + PluginActionTable, + WorkflowTable, + WorkflowFilterTable, + WorkflowActionTable, ]; functions = [ @@ -231,4 +239,12 @@ export interface DB { user_metadata_audit: UserMetadataAuditTable; version_history: VersionHistoryTable; + + plugin: PluginTable; + plugin_filter: PluginFilterTable; + plugin_action: PluginActionTable; + + workflow: WorkflowTable; + workflow_filter: WorkflowFilterTable; + workflow_action: WorkflowActionTable; } diff --git a/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts new file mode 100644 index 0000000000..6dacc1056b --- /dev/null +++ b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts @@ -0,0 +1,113 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "plugin" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "author" character varying NOT NULL, + "version" character varying NOT NULL, + "wasmPath" character varying NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "plugin_name_uq" UNIQUE ("name"), + CONSTRAINT "plugin_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db); + await sql`CREATE TABLE "plugin_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_filter_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_filter_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_filter_supportedContexts_idx" ON "plugin_filter" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_filter_pluginId_idx" ON "plugin_filter" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_filter_methodName_idx" ON "plugin_filter" ("methodName");`.execute(db); + await sql`CREATE TABLE "plugin_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_action_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_action_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_action_supportedContexts_idx" ON "plugin_action" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_action_pluginId_idx" ON "plugin_action" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_action_methodName_idx" ON "plugin_action" ("methodName");`.execute(db); + await sql`CREATE TABLE "workflow" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "triggerType" character varying NOT NULL, + "name" character varying, + "description" character varying NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "enabled" boolean NOT NULL DEFAULT true, + CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db); + await sql`CREATE TABLE "workflow_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "filterId" uuid NOT NULL, + "filterConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_filter_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db); + await sql`CREATE INDEX "workflow_filter_workflowId_order_idx" ON "workflow_filter" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_filter_workflowId_idx" ON "workflow_filter" ("workflowId");`.execute(db); + await sql`CREATE TABLE "workflow_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "actionId" uuid NOT NULL, + "actionConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_action_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db); + await sql`CREATE INDEX "workflow_action_workflowId_order_idx" ON "workflow_action" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_action_workflowId_idx" ON "workflow_action" ("workflowId");`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_filter_supportedContexts_idx', '{"type":"index","name":"plugin_filter_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_filter_supportedContexts_idx\\" ON \\"plugin_filter\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_action_supportedContexts_idx', '{"type":"index","name":"plugin_action_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_action_supportedContexts_idx\\" ON \\"plugin_action\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "workflow";`.execute(db); + await sql`DROP TABLE "workflow_filter";`.execute(db); + await sql`DROP TABLE "workflow_action";`.execute(db); + + await sql`DROP TABLE "plugin";`.execute(db); + await sql`DROP TABLE "plugin_filter";`.execute(db); + await sql`DROP TABLE "plugin_action";`.execute(db); + + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db); +} diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts new file mode 100644 index 0000000000..3de7ca63c9 --- /dev/null +++ b/server/src/schema/tables/plugin.table.ts @@ -0,0 +1,95 @@ +import { PluginContext } from 'src/enum'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from 'src/sql-tools'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; + +@Table('plugin') +export class PluginTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @Column({ index: true, unique: true }) + name!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column() + author!: string; + + @Column() + version!: string; + + @Column() + wasmPath!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; +} + +@Index({ columns: ['supportedContexts'], using: 'gin' }) +@Table('plugin_filter') +export class PluginFilterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @Column({ index: true }) + pluginId!: string; + + @Column({ index: true, unique: true }) + methodName!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + supportedContexts!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} + +@Index({ columns: ['supportedContexts'], using: 'gin' }) +@Table('plugin_action') +export class PluginActionTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @Column({ index: true }) + pluginId!: string; + + @Column({ index: true, unique: true }) + methodName!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + supportedContexts!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts new file mode 100644 index 0000000000..8f7c9adb0d --- /dev/null +++ b/server/src/schema/tables/workflow.table.ts @@ -0,0 +1,78 @@ +import { PluginTriggerType } from 'src/enum'; +import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, +} from 'src/sql-tools'; +import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; + +@Table('workflow') +export class WorkflowTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + ownerId!: string; + + @Column() + triggerType!: PluginTriggerType; + + @Column({ nullable: true }) + name!: string | null; + + @Column() + description!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['filterId'] }) +@Table('workflow_filter') +export class WorkflowFilterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + filterId!: string; + + @Column({ type: 'jsonb', nullable: true }) + filterConfig!: FilterConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['actionId'] }) +@Table('workflow_action') +export class WorkflowActionTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + actionId!: string; + + @Column({ type: 'jsonb', nullable: true }) + actionConfig!: ActionConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 8cf1ef331d..4db60c349f 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -426,6 +426,9 @@ export class AssetMediaService extends BaseService { } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); + + await this.eventRepository.emit('AssetCreate', { asset }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); return asset; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 51041c1b1a..2c6d07b635 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -35,6 +35,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -53,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { UserTable } from 'src/schema/tables/user.table'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -88,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, ServerInfoRepository, @@ -105,6 +108,8 @@ export const BASE_SERVICE_DEPENDENCIES = [ UserRepository, VersionHistoryRepository, ViewRepository, + WebsocketRepository, + WorkflowRepository, ]; @Injectable() @@ -142,6 +147,7 @@ export class BaseService { protected ocrRepository: OcrRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, + protected pluginRepository: PluginRepository, protected processRepository: ProcessRepository, protected searchRepository: SearchRepository, protected serverInfoRepository: ServerInfoRepository, @@ -160,6 +166,7 @@ export class BaseService { protected versionRepository: VersionHistoryRepository, protected viewRepository: ViewRepository, protected websocketRepository: WebsocketRepository, + protected workflowRepository: WorkflowRepository, ) { this.logger.setContext(this.constructor.name); this.storageCore = StorageCore.create( diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 8862a5b37e..9d09bdaa53 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -23,6 +23,7 @@ import { NotificationService } from 'src/services/notification.service'; import { OcrService } from 'src/services/ocr.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; +import { PluginService } from 'src/services/plugin.service'; import { QueueService } from 'src/services/queue.service'; import { SearchService } from 'src/services/search.service'; import { ServerService } from 'src/services/server.service'; @@ -43,6 +44,7 @@ import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { ViewService } from 'src/services/view.service'; +import { WorkflowService } from 'src/services/workflow.service'; export const services = [ ApiKeyService, @@ -70,6 +72,7 @@ export const services = [ OcrService, PartnerService, PersonService, + PluginService, QueueService, SearchService, ServerService, @@ -90,4 +93,5 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowService, ]; diff --git a/server/src/services/plugin-host.functions.ts b/server/src/services/plugin-host.functions.ts new file mode 100644 index 0000000000..50b1052b54 --- /dev/null +++ b/server/src/services/plugin-host.functions.ts @@ -0,0 +1,120 @@ +import { CurrentPlugin } from '@extism/extism'; +import { UnauthorizedException } from '@nestjs/common'; +import { Updateable } from 'kysely'; +import { Permission } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { requireAccess } from 'src/utils/access'; + +/** + * Plugin host functions that are exposed to WASM plugins via Extism. + * These functions allow plugins to interact with the Immich system. + */ +export class PluginHostFunctions { + constructor( + private assetRepository: AssetRepository, + private albumRepository: AlbumRepository, + private accessRepository: AccessRepository, + private cryptoRepository: CryptoRepository, + private logger: LoggingRepository, + private pluginJwtSecret: string, + ) {} + + /** + * Creates Extism host function bindings for the plugin. + * These are the functions that WASM plugins can call. + */ + getHostFunctions() { + return { + 'extism:host/user': { + updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs), + addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs), + }, + }; + } + + /** + * Host function wrapper for updateAsset. + * Reads the input from the plugin, parses it, and calls the actual update function. + */ + private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) { + const input = JSON.parse(cp.read(offs)!.text()); + await this.updateAsset(input); + } + + /** + * Host function wrapper for addAssetToAlbum. + * Reads the input from the plugin, parses it, and calls the actual add function. + */ + private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) { + const input = JSON.parse(cp.read(offs)!.text()); + await this.addAssetToAlbum(input); + } + + /** + * Validates the JWT token and returns the auth context. + */ + private validateToken(authToken: string): { userId: string } { + try { + const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret); + if (!auth.userId) { + throw new UnauthorizedException('Invalid token: missing userId'); + } + return auth; + } catch (error) { + this.logger.error('Token validation failed:', error); + throw new UnauthorizedException('Invalid token'); + } + } + + /** + * Updates an asset with the given properties. + */ + async updateAsset(input: { authToken: string } & Updateable & { id: string }) { + const { authToken, id, ...assetData } = input; + + // Validate token + const auth = this.validateToken(authToken); + + // Check access to the asset + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AssetUpdate, + ids: [id], + }); + + this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`); + await this.assetRepository.update({ id, ...assetData }); + } + + /** + * Adds an asset to an album. + */ + async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) { + const { authToken, assetId, albumId } = input; + + // Validate token + const auth = this.validateToken(authToken); + + // Check access to both the asset and the album + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AssetRead, + ids: [assetId], + }); + + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AlbumUpdate, + ids: [albumId], + }); + + this.logger.log(`Adding asset ${assetId} to album ${albumId}`); + await this.albumRepository.addAssetIds(albumId, [assetId]); + return 0; + } +} diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts new file mode 100644 index 0000000000..28d1ac56ca --- /dev/null +++ b/server/src/services/plugin.service.ts @@ -0,0 +1,317 @@ +import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validateOrReject } from 'class-validator'; +import { join } from 'node:path'; +import { Asset, WorkflowAction, WorkflowFilter } from 'src/database'; +import { OnEvent, OnJob } from 'src/decorators'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto'; +import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { BaseService } from 'src/services/base.service'; +import { PluginHostFunctions } from 'src/services/plugin-host.functions'; +import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types'; + +interface WorkflowContext { + authToken: string; + asset: Asset; +} + +interface PluginInput { + authToken: string; + config: T; + data: { + asset: Asset; + }; +} + +@Injectable() +export class PluginService extends BaseService { + private pluginJwtSecret!: string; + private loadedPlugins: Map = new Map(); + private hostFunctions!: PluginHostFunctions; + + @OnEvent({ name: 'AppBootstrap' }) + async onBootstrap() { + this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32); + + await this.loadPluginsFromManifests(); + + this.hostFunctions = new PluginHostFunctions( + this.assetRepository, + this.albumRepository, + this.accessRepository, + this.cryptoRepository, + this.logger, + this.pluginJwtSecret, + ); + + await this.loadPlugins(); + } + + // + // CRUD operations for plugins + // + async getAll(): Promise { + const plugins = await this.pluginRepository.getAllPlugins(); + return plugins.map((plugin) => mapPlugin(plugin)); + } + + async get(id: string): Promise { + const plugin = await this.pluginRepository.getPlugin(id); + if (!plugin) { + throw new BadRequestException('Plugin not found'); + } + return mapPlugin(plugin); + } + + /////////////////////////////////////////// + // Plugin Loader + ////////////////////////////////////////// + async loadPluginsFromManifests(): Promise { + // Load core plugin + const { resourcePaths, plugins } = this.configRepository.getEnv(); + const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`; + + const coreManifest = await this.readAndValidateManifest(coreManifestPath); + await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin); + + this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); + + // Load external plugins + if (plugins.enabled && plugins.installFolder) { + await this.loadExternalPlugins(plugins.installFolder); + } + } + + private async loadExternalPlugins(installFolder: string): Promise { + try { + const entries = await this.pluginRepository.readDirectory(installFolder); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const pluginFolder = join(installFolder, entry.name); + const manifestPath = join(pluginFolder, 'manifest.json'); + try { + const manifest = await this.readAndValidateManifest(manifestPath); + await this.loadPluginToDatabase(manifest, pluginFolder); + + this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`); + } catch (error) { + this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error); + } + } + } catch (error) { + this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error); + } + } + + private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { + const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); + if (currentPlugin != null && currentPlugin.version === manifest.version) { + this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); + return; + } + + const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath); + + this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`); + + for (const filter of filters) { + this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`); + } + + for (const action of actions) { + this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`); + } + } + + private async readAndValidateManifest(manifestPath: string): Promise { + const content = await this.storageRepository.readTextFile(manifestPath); + const manifestData = JSON.parse(content); + const manifest = plainToInstance(PluginManifestDto, manifestData); + + await validateOrReject(manifest, { + whitelist: true, + forbidNonWhitelisted: true, + }); + + return manifest; + } + + /////////////////////////////////////////// + // Plugin Execution + /////////////////////////////////////////// + private async loadPlugins() { + const plugins = await this.pluginRepository.getAllPlugins(); + for (const plugin of plugins) { + try { + this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`); + + const extismPlugin = await newPlugin(plugin.wasmPath, { + useWasi: true, + functions: this.hostFunctions.getHostFunctions(), + }); + + this.loadedPlugins.set(plugin.id, extismPlugin); + this.logger.log(`Successfully loaded plugin: ${plugin.name}`); + } catch (error) { + this.logger.error(`Failed to load plugin ${plugin.name}:`, error); + } + } + } + + @OnEvent({ name: 'AssetCreate' }) + async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) { + await this.handleTrigger(PluginTriggerType.AssetCreate, { + ownerId: asset.ownerId, + event: { userId: asset.ownerId, asset }, + }); + } + + private async handleTrigger( + triggerType: T, + params: { ownerId: string; event: WorkflowData[T] }, + ): Promise { + const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType); + if (workflows.length === 0) { + return; + } + + const jobs: JobItem[] = workflows.map((workflow) => ({ + name: JobName.WorkflowRun, + data: { + id: workflow.id, + type: triggerType, + event: params.event, + } as IWorkflowJob, + })); + + await this.jobRepository.queueAll(jobs); + this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`); + } + + @OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow }) + async handleWorkflowRun({ id: workflowId, type, event }: JobOf): Promise { + try { + const workflow = await this.workflowRepository.getWorkflow(workflowId); + if (!workflow) { + this.logger.error(`Workflow ${workflowId} not found`); + return JobStatus.Failed; + } + + const workflowFilters = await this.workflowRepository.getFilters(workflowId); + const workflowActions = await this.workflowRepository.getActions(workflowId); + + switch (type) { + case PluginTriggerType.AssetCreate: { + const data = event as WorkflowData[PluginTriggerType.AssetCreate]; + const asset = data.asset; + + const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret); + + const context = { + authToken, + asset, + }; + + const filtersPassed = await this.executeFilters(workflowFilters, context); + if (!filtersPassed) { + return JobStatus.Skipped; + } + + await this.executeActions(workflowActions, context); + this.logger.debug(`Workflow ${workflowId} executed successfully`); + return JobStatus.Success; + } + + case PluginTriggerType.PersonRecognized: { + this.logger.error('unimplemented'); + return JobStatus.Skipped; + } + + default: { + this.logger.error(`Unknown workflow trigger type: ${type}`); + return JobStatus.Failed; + } + } + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId}:`, error); + return JobStatus.Failed; + } + } + + private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise { + for (const workflowFilter of workflowFilters) { + const filter = await this.pluginRepository.getFilter(workflowFilter.filterId); + if (!filter) { + this.logger.error(`Filter ${workflowFilter.filterId} not found`); + return false; + } + + const pluginInstance = this.loadedPlugins.get(filter.pluginId); + if (!pluginInstance) { + this.logger.error(`Plugin ${filter.pluginId} not loaded`); + return false; + } + + const filterInput: PluginInput = { + authToken: context.authToken, + config: workflowFilter.filterConfig, + data: { + asset: context.asset, + }, + }; + + this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`); + + const filterResult = await pluginInstance.call( + filter.methodName, + new TextEncoder().encode(JSON.stringify(filterInput)), + ); + + if (!filterResult) { + this.logger.error(`Filter ${filter.methodName} returned null`); + return false; + } + + const result = JSON.parse(filterResult.text()); + if (result.passed === false) { + this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`); + return false; + } + } + + return true; + } + + private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise { + for (const workflowAction of workflowActions) { + const action = await this.pluginRepository.getAction(workflowAction.actionId); + if (!action) { + throw new Error(`Action ${workflowAction.actionId} not found`); + } + + const pluginInstance = this.loadedPlugins.get(action.pluginId); + if (!pluginInstance) { + throw new Error(`Plugin ${action.pluginId} not loaded`); + } + + const actionInput: PluginInput = { + authToken: context.authToken, + config: workflowAction.actionConfig, + data: { + asset: context.asset, + }, + }; + + this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`); + + await pluginInstance.call(action.methodName, JSON.stringify(actionInput)); + } + } +} diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 1cc53df644..5dce9476e2 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -22,7 +22,7 @@ describe(QueueService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); @@ -97,6 +97,7 @@ describe(QueueService.name, () => { [QueueName.Notification]: expectedJobStatus, [QueueName.BackupDatabase]: expectedJobStatus, [QueueName.Ocr]: expectedJobStatus, + [QueueName.Workflow]: expectedJobStatus, }); }); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index b9a38e4b06..fbdd655bbc 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -40,6 +40,7 @@ const updatedConfig = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, backup: { database: { diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts new file mode 100644 index 0000000000..ae72187d7d --- /dev/null +++ b/server/src/services/workflow.service.ts @@ -0,0 +1,159 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Workflow } from 'src/database'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapWorkflowAction, + mapWorkflowFilter, + WorkflowCreateDto, + WorkflowResponseDto, + WorkflowUpdateDto, +} from 'src/dtos/workflow.dto'; +import { Permission, PluginContext, PluginTriggerType } from 'src/enum'; +import { pluginTriggers } from 'src/plugins'; + +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class WorkflowService extends BaseService { + async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { + const trigger = this.getTriggerOrFail(dto.triggerType); + + const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context); + const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context); + + const workflow = await this.workflowRepository.createWorkflow( + { + ownerId: auth.user.id, + triggerType: dto.triggerType, + name: dto.name, + description: dto.description || '', + enabled: dto.enabled ?? true, + }, + filterInserts, + actionInserts, + ); + + return this.mapWorkflow(workflow); + } + + async getAll(auth: AuthDto): Promise { + const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id); + + return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow))); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); + const workflow = await this.findOrFail(id); + return this.mapWorkflow(workflow); + } + + async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] }); + + if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { + throw new BadRequestException('No fields to update'); + } + + const workflow = await this.findOrFail(id); + const trigger = this.getTriggerOrFail(workflow.triggerType); + + const { filters, actions, ...workflowUpdate } = dto; + const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context)); + const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context)); + + const updatedWorkflow = await this.workflowRepository.updateWorkflow( + id, + workflowUpdate, + filterInserts, + actionInserts, + ); + + return this.mapWorkflow(updatedWorkflow); + } + + async delete(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] }); + await this.workflowRepository.deleteWorkflow(id); + } + + private async validateAndMapFilters( + filters: Array<{ filterId: string; filterConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of filters) { + const filter = await this.pluginRepository.getFilter(dto.filterId); + if (!filter) { + throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`); + } + + if (!filter.supportedContexts.includes(requiredContext)) { + throw new BadRequestException( + `Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`, + ); + } + } + + return filters.map((dto, index) => ({ + filterId: dto.filterId, + filterConfig: dto.filterConfig || null, + order: index, + })); + } + + private async validateAndMapActions( + actions: Array<{ actionId: string; actionConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of actions) { + const action = await this.pluginRepository.getAction(dto.actionId); + if (!action) { + throw new BadRequestException(`Invalid action ID: ${dto.actionId}`); + } + if (!action.supportedContexts.includes(requiredContext)) { + throw new BadRequestException( + `Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`, + ); + } + } + + return actions.map((dto, index) => ({ + actionId: dto.actionId, + actionConfig: dto.actionConfig || null, + order: index, + })); + } + + private getTriggerOrFail(triggerType: PluginTriggerType) { + const trigger = pluginTriggers.find((t) => t.type === triggerType); + if (!trigger) { + throw new BadRequestException(`Invalid trigger type: ${triggerType}`); + } + return trigger; + } + + private async findOrFail(id: string) { + const workflow = await this.workflowRepository.getWorkflow(id); + if (!workflow) { + throw new BadRequestException('Workflow not found'); + } + return workflow; + } + + private async mapWorkflow(workflow: Workflow): Promise { + const filters = await this.workflowRepository.getFilters(workflow.id); + const actions = await this.workflowRepository.getActions(workflow.id); + + return { + id: workflow.id, + ownerId: workflow.ownerId, + triggerType: workflow.triggerType, + name: workflow.name, + description: workflow.description, + createdAt: workflow.createdAt.toISOString(), + enabled: workflow.enabled, + filters: filters.map((f) => mapWorkflowFilter(f)), + actions: actions.map((a) => mapWorkflowAction(a)), + }; + } +} diff --git a/server/src/types.ts b/server/src/types.ts index afc72480e8..ad947e3774 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,5 +1,6 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; +import { Asset } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -11,6 +12,7 @@ import { ImageFormat, JobName, MemoryType, + PluginTriggerType, QueueName, StorageFolder, SyncEntityType, @@ -263,6 +265,23 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } +export interface WorkflowData { + [PluginTriggerType.AssetCreate]: { + userId: string; + asset: Asset; + }; + [PluginTriggerType.PersonRecognized]: { + personId: string; + assetId: string; + }; +} + +export interface IWorkflowJob { + id: string; + type: T; + event: WorkflowData[T]; +} + export interface JobCounts { active: number; completed: number; @@ -374,7 +393,10 @@ export type JobItem = // OCR | { name: JobName.OcrQueueAll; data: IBaseJob } - | { name: JobName.Ocr; data: IEntityJob }; + | { name: JobName.Ocr; data: IEntityJob } + + // Workflow + | { name: JobName.WorkflowRun; data: IWorkflowJob }; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; diff --git a/server/src/types/plugin-schema.types.ts b/server/src/types/plugin-schema.types.ts new file mode 100644 index 0000000000..793bb3c1ff --- /dev/null +++ b/server/src/types/plugin-schema.types.ts @@ -0,0 +1,35 @@ +/** + * JSON Schema types for plugin configuration schemas + * Based on JSON Schema Draft 7 + */ + +export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; + +export interface JSONSchemaProperty { + type?: JSONSchemaType | JSONSchemaType[]; + description?: string; + default?: any; + enum?: any[]; + items?: JSONSchemaProperty; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JSONSchemaProperty; +} + +export interface JSONSchema { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + description?: string; +} + +export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; + +export interface FilterConfig { + [key: string]: ConfigValue; +} + +export interface ActionConfig { + [key: string]: ConfigValue; +} diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 7a0f701f74..f8d5f0ca08 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -298,6 +298,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.stack.checkOwnerAccess(auth.user.id, ids); } + case Permission.WorkflowRead: + case Permission.WorkflowUpdate: + case Permission.WorkflowDelete: { + return access.workflow.checkOwnerAccess(auth.user.id, ids); + } + default: { return new Set(); } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 6167b6a6ff..efcdc59793 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -36,6 +36,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; @@ -49,6 +50,7 @@ import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -380,6 +382,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -389,7 +392,8 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: - case VersionHistoryRepository: { + case VersionHistoryRepository: + case WorkflowRepository: { return new key(db); } @@ -441,13 +445,15 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: case VersionHistoryRepository: - case TagRepository: { + case TagRepository: + case WorkflowRepository: { return automock(key); } diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts new file mode 100644 index 0000000000..b70e8e8d54 --- /dev/null +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -0,0 +1,308 @@ +import { Kysely } from 'kysely'; +import { PluginContext } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { DB } from 'src/schema'; +import { PluginService } from 'src/services/plugin.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; +let pluginRepo: PluginRepository; + +const setup = (db?: Kysely) => { + return newMediumService(PluginService, { + database: db || defaultDatabase, + real: [PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); + pluginRepo = new PluginRepository(defaultDatabase); +}); + +afterEach(async () => { + await defaultDatabase.deleteFrom('plugin').execute(); +}); + +describe(PluginService.name, () => { + describe('getAll', () => { + it('should return empty array when no plugins exist', async () => { + const { sut } = setup(); + + const plugins = await sut.getAll(); + + expect(plugins).toEqual([]); + }); + + it('should return plugin without filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'test-plugin', + title: 'Test Plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/test.wasm' }, + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'test-plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + filters: [], + actions: [], + }); + }); + + it('should return plugin with filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'full-plugin', + title: 'Full Plugin', + description: 'A plugin with filters and actions', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/full.wasm' }, + filters: [ + { + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + actions: [ + { + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'full-plugin', + filters: [ + { + id: result.filters[0].id, + pluginId: result.plugin.id, + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + actions: [ + { + id: result.actions[0].id, + pluginId: result.plugin.id, + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + }); + }); + + it('should return multiple plugins with their respective filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + name: 'plugin-1', + title: 'Plugin 1', + description: 'First plugin', + author: 'Author 1', + version: '1.0.0', + wasm: { path: '/path/to/plugin1.wasm' }, + filters: [ + { + methodName: 'filter-1', + title: 'Filter 1', + description: 'Filter for plugin 1', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + await pluginRepo.loadPlugin( + { + name: 'plugin-2', + title: 'Plugin 2', + description: 'Second plugin', + author: 'Author 2', + version: '2.0.0', + wasm: { path: '/path/to/plugin2.wasm' }, + actions: [ + { + methodName: 'action-2', + title: 'Action 2', + description: 'Action for plugin 2', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(2); + expect(plugins[0].name).toBe('plugin-1'); + expect(plugins[0].filters).toHaveLength(1); + expect(plugins[0].actions).toHaveLength(0); + + expect(plugins[1].name).toBe('plugin-2'); + expect(plugins[1].filters).toHaveLength(0); + expect(plugins[1].actions).toHaveLength(1); + }); + + it('should handle plugin with multiple filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + name: 'multi-plugin', + title: 'Multi Plugin', + description: 'Plugin with multiple items', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/multi.wasm' }, + filters: [ + { + methodName: 'filter-a', + title: 'Filter A', + description: 'First filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + { + methodName: 'filter-b', + title: 'Filter B', + description: 'Second filter', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'action-x', + title: 'Action X', + description: 'First action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + { + methodName: 'action-y', + title: 'Action Y', + description: 'Second action', + supportedContexts: [PluginContext.Person], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0].filters).toHaveLength(2); + expect(plugins[0].actions).toHaveLength(2); + }); + }); + + describe('get', () => { + it('should throw error when plugin does not exist', async () => { + const { sut } = setup(); + + await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found'); + }); + + it('should return single plugin with filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'single-plugin', + title: 'Single Plugin', + description: 'A single plugin', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/single.wasm' }, + filters: [ + { + methodName: 'single-filter', + title: 'Single Filter', + description: 'A single filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'single-action', + title: 'Single Action', + description: 'A single action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const pluginResult = await sut.get(result.plugin.id); + + expect(pluginResult).toMatchObject({ + id: result.plugin.id, + name: 'single-plugin', + filters: [ + { + id: result.filters[0].id, + methodName: 'single-filter', + title: 'Single Filter', + }, + ], + actions: [ + { + id: result.actions[0].id, + methodName: 'single-action', + title: 'Single Action', + }, + ], + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts new file mode 100644 index 0000000000..af12019ef6 --- /dev/null +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -0,0 +1,697 @@ +import { Kysely } from 'kysely'; +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; +import { DB } from 'src/schema'; +import { WorkflowService } from 'src/services/workflow.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(WorkflowService, { + database: db || defaultDatabase, + real: [WorkflowRepository, PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(WorkflowService.name, () => { + let testPluginId: string; + let testFilterId: string; + let testActionId: string; + + beforeAll(async () => { + // Create a test plugin with filters and actions once for all tests + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'test-core-plugin', + title: 'Test Core Plugin', + description: 'A test core plugin for workflow tests', + author: 'Test Author', + version: '1.0.0', + wasm: { + path: '/test/path.wasm', + }, + filters: [ + { + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + testPluginId = result.plugin.id; + testFilterId = result.filters[0].id; + testActionId = result.actions[0].id; + }); + + afterAll(async () => { + await defaultDatabase.deleteFrom('plugin').where('id', '=', testPluginId).execute(); + }); + + describe('create', () => { + it('should create a workflow without filters or actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [], + actions: [], + }); + + expect(workflow).toMatchObject({ + id: expect.any(String), + ownerId: user.id, + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [], + actions: [], + }); + }); + + it('should create a workflow with filters and actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow-with-relations', + description: 'A test workflow with filters and actions', + enabled: true, + filters: [ + { + filterId: testFilterId, + filterConfig: { key: 'value' }, + }, + ], + actions: [ + { + actionId: testActionId, + actionConfig: { action: 'test' }, + }, + ], + }); + + expect(workflow).toMatchObject({ + id: expect.any(String), + ownerId: user.id, + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow-with-relations', + enabled: true, + }); + + expect(workflow.filters).toHaveLength(1); + expect(workflow.filters[0]).toMatchObject({ + id: expect.any(String), + workflowId: workflow.id, + filterId: testFilterId, + filterConfig: { key: 'value' }, + order: 0, + }); + + expect(workflow.actions).toHaveLength(1); + expect(workflow.actions[0]).toMatchObject({ + id: expect.any(String), + workflowId: workflow.id, + actionId: testActionId, + actionConfig: { action: 'test' }, + order: 0, + }); + }); + + it('should throw error when creating workflow with invalid filter', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid filter', + enabled: true, + filters: [ + { + filterId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + filterConfig: { key: 'value' }, + }, + ], + actions: [], + }), + ).rejects.toThrow('Invalid filter ID'); + }); + + it('should throw error when creating workflow with invalid action', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid action', + enabled: true, + filters: [], + actions: [ + { + actionId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + actionConfig: { action: 'test' }, + }, + ], + }), + ).rejects.toThrow('Invalid action ID'); + }); + + it('should throw error when filter does not support trigger context', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + // Create a plugin with a filter that only supports Album context + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'album-only-plugin', + title: 'Album Only Plugin', + description: 'Plugin with album-only filter', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/test/album-plugin.wasm' }, + filters: [ + { + methodName: 'album-filter', + title: 'Album Filter', + description: 'A filter that only works with albums', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-context-workflow', + description: 'A workflow with context mismatch', + enabled: true, + filters: [{ filterId: result.filters[0].id }], + actions: [], + }), + ).rejects.toThrow('does not support asset context'); + }); + + it('should throw error when action does not support trigger context', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + // Create a plugin with an action that only supports Person context + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'person-only-plugin', + title: 'Person Only Plugin', + description: 'Plugin with person-only action', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/test/person-plugin.wasm' }, + actions: [ + { + methodName: 'person-action', + title: 'Person Action', + description: 'An action that only works with persons', + supportedContexts: [PluginContext.Person], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-context-workflow', + description: 'A workflow with context mismatch', + enabled: true, + filters: [], + actions: [{ actionId: result.actions[0].id }], + }), + ).rejects.toThrow('does not support asset context'); + }); + + it('should create workflow with multiple filters and actions in correct order', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'multi-step-workflow', + description: 'A workflow with multiple filters and actions', + enabled: true, + filters: [ + { filterId: testFilterId, filterConfig: { step: 1 } }, + { filterId: testFilterId, filterConfig: { step: 2 } }, + ], + actions: [ + { actionId: testActionId, actionConfig: { step: 1 } }, + { actionId: testActionId, actionConfig: { step: 2 } }, + { actionId: testActionId, actionConfig: { step: 3 } }, + ], + }); + + expect(workflow.filters).toHaveLength(2); + expect(workflow.filters[0].order).toBe(0); + expect(workflow.filters[0].filterConfig).toEqual({ step: 1 }); + expect(workflow.filters[1].order).toBe(1); + expect(workflow.filters[1].filterConfig).toEqual({ step: 2 }); + + expect(workflow.actions).toHaveLength(3); + expect(workflow.actions[0].order).toBe(0); + expect(workflow.actions[1].order).toBe(1); + expect(workflow.actions[2].order).toBe(2); + }); + }); + + describe('getAll', () => { + it('should return all workflows for a user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow1 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-1', + description: 'First workflow', + enabled: true, + filters: [], + actions: [], + }); + + const workflow2 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-2', + description: 'Second workflow', + enabled: false, + filters: [], + actions: [], + }); + + const workflows = await sut.getAll(auth); + + expect(workflows).toHaveLength(2); + expect(workflows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: workflow1.id, name: 'workflow-1' }), + expect.objectContaining({ id: workflow2.id, name: 'workflow-2' }), + ]), + ); + }); + + it('should return empty array when user has no workflows', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflows = await sut.getAll(auth); + + expect(workflows).toEqual([]); + }); + + it('should not return workflows from other users', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'user1-workflow', + description: 'User 1 workflow', + enabled: true, + filters: [], + actions: [], + }); + + const user2Workflows = await sut.getAll(auth2); + + expect(user2Workflows).toEqual([]); + }); + }); + + describe('get', () => { + it('should return a specific workflow by id', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }], + actions: [{ actionId: testActionId, actionConfig: { action: 'test' } }], + }); + + const workflow = await sut.get(auth, created.id); + + expect(workflow).toMatchObject({ + id: created.id, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + }); + expect(workflow.filters).toHaveLength(1); + expect(workflow.actions).toHaveLength(1); + }); + + it('should throw error when workflow does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow(); + }); + + it('should throw error when user does not have access to workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private workflow', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.get(auth2, workflow.id)).rejects.toThrow(); + }); + }); + + describe('update', () => { + it('should update workflow basic fields', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'original-workflow', + description: 'Original description', + enabled: true, + filters: [], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + name: 'updated-workflow', + description: 'Updated description', + enabled: false, + }); + + expect(updated).toMatchObject({ + id: created.id, + name: 'updated-workflow', + description: 'Updated description', + enabled: false, + }); + }); + + it('should update workflow filters', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { old: 'config' } }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [ + { filterId: testFilterId, filterConfig: { new: 'config' } }, + { filterId: testFilterId, filterConfig: { second: 'filter' } }, + ], + }); + + expect(updated.filters).toHaveLength(2); + expect(updated.filters[0].filterConfig).toEqual({ new: 'config' }); + expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' }); + }); + + it('should update workflow actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [{ actionId: testActionId, actionConfig: { old: 'config' } }], + }); + + const updated = await sut.update(auth, created.id, { + actions: [ + { actionId: testActionId, actionConfig: { new: 'config' } }, + { actionId: testActionId, actionConfig: { second: 'action' } }, + ], + }); + + expect(updated.actions).toHaveLength(2); + expect(updated.actions[0].actionConfig).toEqual({ new: 'config' }); + expect(updated.actions[1].actionConfig).toEqual({ second: 'action' }); + }); + + it('should clear filters when updated with empty array', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [], + }); + + expect(updated.filters).toHaveLength(0); + }); + + it('should throw error when no fields to update', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update'); + }); + + it('should throw error when updating non-existent workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.update(auth, 'non-existent-id', { + name: 'updated-name', + }), + ).rejects.toThrow(); + }); + + it('should throw error when user does not have access to update workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth2, workflow.id, { + name: 'hacked-workflow', + }), + ).rejects.toThrow(); + }); + + it('should throw error when updating with invalid filter', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth, created.id, { + filters: [{ filterId: 'invalid-filter-id', filterConfig: {} }], + }), + ).rejects.toThrow(); + }); + + it('should throw error when updating with invalid action', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth, created.id, { + actions: [{ actionId: 'invalid-action-id', actionConfig: {} }], + }), + ).rejects.toThrow(); + }); + }); + + describe('delete', () => { + it('should delete a workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await sut.delete(auth, workflow.id); + + await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); + }); + + it('should delete workflow with filters and actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: {} }], + actions: [{ actionId: testActionId, actionConfig: {} }], + }); + + await sut.delete(auth, workflow.id); + + await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); + }); + + it('should throw error when deleting non-existent workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect(sut.delete(auth, 'non-existent-id')).rejects.toThrow(); + }); + + it('should throw error when user does not have access to delete workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.delete(auth2, workflow.id)).rejects.toThrow(); + }); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 50db983cba..208b09c120 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -65,5 +65,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { tag: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + + workflow: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index e31e1a3348..656027fab5 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -72,6 +72,7 @@ const envData: EnvData = { root: '/build/www', indexHtml: '/build/www/index.html', }, + corePlugin: '/build/corePlugin', }, storage: { @@ -86,6 +87,11 @@ const envData: EnvData = { workers: [ImmichWorker.Api, ImmichWorker.Microservices], + plugins: { + enabled: true, + installFolder: '/app/data/plugins', + }, + noColor: false, }; diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index 1167923c0c..773891206e 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -13,5 +13,7 @@ export const newCryptoRepositoryMock = (): Mocked Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), + signJwt: vitest.fn().mockReturnValue('mock-jwt-token'), + verifyJwt: vitest.fn().mockImplementation((token) => ({ verified: true, token })), }; }; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 9752a39441..31451da82f 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -49,6 +49,7 @@ export const newStorageRepositoryMock = (): Mocked = T extends RepositoryInterface ? U : never; @@ -308,6 +312,7 @@ export const newTestService = ( oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), + plugin: automock(PluginRepository, { strict: true }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), // eslint-disable-next-line no-sparse-arrays @@ -330,6 +335,7 @@ export const newTestService = ( view: automock(ViewRepository), // eslint-disable-next-line no-sparse-arrays websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }), + workflow: automock(WorkflowRepository, { strict: true }), }; const sut = new Service( @@ -363,6 +369,7 @@ export const newTestService = ( overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), + overrides.plugin || (mocks.plugin as As), overrides.process || (mocks.process as As), overrides.search || (mocks.search as As), overrides.serverInfo || (mocks.serverInfo as As), @@ -381,6 +388,7 @@ export const newTestService = ( overrides.versionHistory || (mocks.versionHistory as As), overrides.view || (mocks.view as As), overrides.websocket || (mocks.websocket as As), + overrides.workflow || (mocks.workflow as As), ); return { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 87f0d7e7bf..100f807273 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -162,6 +162,7 @@ export const getQueueName = derived(t, ($t) => { [QueueName.Notifications]: $t('notifications'), [QueueName.BackupDatabase]: $t('admin.backup_database'), [QueueName.Ocr]: $t('admin.machine_learning_ocr'), + [QueueName.Workflow]: $t('workflow'), }; return names[name]; From e94eb5012f021577589ddb9bad4a35266f3d86aa Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 14 Nov 2025 22:11:47 +0100 Subject: [PATCH 93/93] feat(mobile): add to album from asset viewer (#23608) * feat: add action button in photo viewer for adding assets to albums, archiving, and moving to locked folders * fix: use const constructors for icons in action button menu * Update mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart Co-authored-by: Brandon Wees * Update mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart Co-authored-by: Brandon Wees * remove de translation * fixed PR comments: https://github.com/immich-app/immich/pull/23608 * menu styling * menu styling * i18n --------- Co-authored-by: Brandon Wees Co-authored-by: Alex --- i18n/en.json | 3 + .../add_action_button.widget.dart | 191 ++++++++++++++++++ .../archive_action_button.widget.dart | 47 +++-- ...e_to_lock_folder_action_button.widget.dart | 53 ++--- .../unarchive_action_button.widget.dart | 47 +++-- .../asset_viewer/bottom_bar.widget.dart | 10 +- 6 files changed, 279 insertions(+), 72 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart diff --git a/i18n/en.json b/i18n/en.json index ce999793d4..6da205d85a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -32,6 +32,7 @@ "add_to_album_toggle": "Toggle selection for {album}", "add_to_albums": "Add to albums", "add_to_albums_count": "Add to albums ({count})", + "add_to_bottom_bar": "Add to", "add_to_shared_album": "Add to shared album", "add_upload_to_stack": "Add upload to stack", "add_url": "Add URL", @@ -430,6 +431,7 @@ "age_months": "Age {months, plural, one {# month} other {# months}}", "age_year_months": "Age 1 year, {months, plural, one {# month} other {# months}}", "age_years": "{years, plural, other {Age #}}", + "album": "Album", "album_added": "Album added", "album_added_notification_setting_description": "Receive an email notification when you are added to a shared album", "album_cover_updated": "Album cover updated", @@ -1385,6 +1387,7 @@ "more": "More", "move": "Move", "move_off_locked_folder": "Move out of locked folder", + "move_to": "Move to", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart new file mode 100644 index 0000000000..9155d82753 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -0,0 +1,191 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/providers/user.provider.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/constants/enums.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; + +enum AddToMenuItem { album, archive, unarchive, lockedFolder } + +class AddActionButton extends ConsumerWidget { + const AddActionButton({super.key}); + + Future _showAddOptions(BuildContext context, WidgetRef ref) async { + final asset = ref.read(currentAssetNotifier); + if (asset == null) return; + + final user = ref.read(currentUserProvider); + final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + final isInLockedView = ref.watch(inLockedViewProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final hasRemote = asset is RemoteAsset; + final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived; + final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived; + final menuItemHeight = 30.0; + + final List> items = [ + PopupMenuItem( + enabled: false, + textStyle: context.textTheme.labelMedium, + height: 40, + child: Text("add_to_bottom_bar".tr()), + ), + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.album, + child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())), + ), + const PopupMenuDivider(), + PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())), + if (isOwner) ...[ + if (showArchive) + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.archive, + child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())), + ), + if (showUnarchive) + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.unarchive, + child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())), + ), + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.lockedFolder, + child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())), + ), + ], + ]; + + final AddToMenuItem? selected = await showMenu( + context: context, + color: context.themeData.scaffoldBackgroundColor, + position: _menuPosition(context), + items: items, + ); + + if (selected == null) { + return; + } + + switch (selected) { + case AddToMenuItem.album: + _openAlbumSelector(context, ref); + break; + case AddToMenuItem.archive: + await performArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.unarchive: + await performUnArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.lockedFolder: + await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer); + break; + } + } + + RelativeRect _menuPosition(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox) { + return RelativeRect.fill; + } + + final size = renderObject.size; + final position = renderObject.localToGlobal(Offset.zero); + + return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy); + } + + void _openAlbumSelector(BuildContext context, WidgetRef ref) { + final currentAsset = ref.read(currentAssetNotifier); + if (currentAsset == null) { + ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); + return; + } + + final List slivers = [ + AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)), + ]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) { + return BaseBottomSheet( + actions: const [], + slivers: slivers, + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.95, + expand: false, + backgroundColor: context.isDarkTheme ? Colors.black : Colors.white, + ); + }, + ); + } + + Future _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async { + final latest = ref.read(currentAssetNotifier); + + if (latest == null) { + ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); + return; + } + + final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]); + + if (!context.mounted) { + return; + } + + if (addedCount == 0) { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}), + ); + } else { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ); + } + + if (!context.mounted) { + return; + } + await Navigator.of(context).maybePop(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + return Builder( + builder: (buttonContext) { + return BaseActionButton( + iconData: Icons.add, + label: "add_to_bottom_bar".tr(), + onPressed: () => _showAddOptions(buttonContext, ref), + ); + }, + ); + } +} 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 d30ba07d0c..290a19f584 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 @@ -10,33 +10,36 @@ 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'; +// used to allow performing archive action from different sources (without duplicating code) +Future performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).archive(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} + class ArchiveActionButton extends ConsumerWidget { final ActionSource source; const ArchiveActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).archive(source); - ref.read(multiSelectProvider.notifier).reset(); - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performArchiveAction(context, ref, source: source); } @override 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 78b9e3cde6..ddc83cb383 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 @@ -10,36 +10,39 @@ 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'; +// Reusable helper: move to locked folder from any source (e.g called from menu) +Future performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'move_to_lock_folder_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} + class MoveToLockFolderActionButton extends ConsumerWidget { final ActionSource source; const MoveToLockFolderActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final successMessage = 'move_to_lock_folder_action_prompt'.t( - context: context, - args: {'count': result.count.toString()}, - ); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performMoveToLockFolderAction(context, ref, source: source); } @override 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 b457a1b4ca..8b04a1b05d 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 @@ -1,3 +1,5 @@ +// dart +// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart` import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,30 +9,39 @@ 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/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 { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).unArchive(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} class UnArchiveActionButton extends ConsumerWidget { final ActionSource source; const UnArchiveActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).unArchive(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performUnArchiveAction(context, ref, source: source); } @override diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 3111512823..14c03ad637 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -3,13 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; @@ -34,7 +33,6 @@ class ViewerBottomBar extends ConsumerWidget { int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final isInLockedView = ref.watch(inLockedViewProvider); - final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; if (!showControls) { opacity = 0; @@ -44,11 +42,9 @@ class ViewerBottomBar extends ConsumerWidget { const ShareActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.type == AssetType.image) const EditImageActionButton(), + if (asset.hasRemote) const AddActionButton(), + if (isOwner) ...[ - if (asset.hasRemote && isOwner && isArchived) - const UnArchiveActionButton(source: ActionSource.viewer) - else - const ArchiveActionButton(source: ActionSource.viewer), asset.isLocalOnly ? const DeleteLocalActionButton(source: ActionSource.viewer) : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),