diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index 12f3ef6715..0000000000 --- a/.mergify.yml +++ /dev/null @@ -1,7 +0,0 @@ -merge_queue: - status_comments: outcomes - -queue_rules: - - name: default - batch_size: 3 - batch_max_wait_time: 2 min diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 399fedae33..782e0192f6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -12,6 +12,8 @@ "editorconfig.editorconfig", "foxundermoon.shell-format", "timonwong.shellcheck", - "bluebrown.yamlfmt" + "bluebrown.yamlfmt", + // TODO TS 7 while it's still in preview + "typescriptteam.native-preview" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 30dac3216e..23d4385964 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,6 +60,8 @@ "**/dist/**": true, "**/node_modules/**": true }, + // TODO remove once the ts7 extension is stable + "js/ts.experimental.useTsgo": true, "js/ts.preferences.importModuleSpecifier": "non-relative", "search.exclude": { "**/.svelte-kit": true, diff --git a/i18n/en.json b/i18n/en.json index 86a3bba166..fe9a773a3e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -30,6 +30,7 @@ "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "add_to_album_bottom_sheet_some_local_assets": "Some local assets could not be added to album", + "add_to_album_item_count": "Add {count, plural, one {# item} other {# items}} to album", "add_to_albums": "Add to albums", "add_to_albums_count": "Add to albums ({count})", "add_to_bottom_bar": "Add to", diff --git a/mobile/lib/constants/locales.dart b/mobile/lib/constants/locales.dart index d689e6cc1e..429a3718da 100644 --- a/mobile/lib/constants/locales.dart +++ b/mobile/lib/constants/locales.dart @@ -7,7 +7,7 @@ const Map locales = { 'Arabic (ar)': Locale('ar'), 'Basque (eu)': Locale('eu'), 'Belarusian (be)': Locale('be'), - 'Bosnian (bl)': Locale('bn'), + 'Bengali (bn)': Locale('bn'), 'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'), 'Bulgarian (bg)': Locale('bg'), 'Catalan (ca)': Locale('ca'), diff --git a/mobile/lib/presentation/actions/action.dart b/mobile/lib/presentation/actions/action.dart index 072c2524be..d880b7ddf7 100644 --- a/mobile/lib/presentation/actions/action.dart +++ b/mobile/lib/presentation/actions/action.dart @@ -33,6 +33,7 @@ final assetsActionProvider = Provider.family.autoDispose, null => const {}, }, }), + dependencies: [multiSelectProvider], ); final clearSelectionProvider = Provider.family.autoDispose((ref, source) { @@ -41,10 +42,11 @@ final clearSelectionProvider = Provider.family.autoDispose, ActionSource>( (ref, source) => ref.watch(assetsActionProvider(source)).owned(ref.watch(authUserProvider).id), + dependencies: [assetsActionProvider], ); abstract class AssetActionBuilder extends ActionBuilder { diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart index f04611c4c0..f8e4a1e038 100644 --- a/mobile/lib/presentation/actions/archive.action.dart +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -22,7 +22,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, .map((asset) => asset.id) .toList(growable: false); return assetIds.isEmpty ? null : (shouldArchive: shouldArchive, assetIds: assetIds); -}); +}, dependencies: [ownedAssetsActionProvider]); class ArchiveAction extends AssetActionBuilder { const ArchiveAction({required super.source}); diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 0fe297bd03..31d01b32ef 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -40,7 +40,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); -}); +}, dependencies: [assetsActionProvider]); class DeleteAction extends AssetActionBuilder { const DeleteAction({required super.source}); @@ -149,7 +149,7 @@ final _cleanupStateProvider = Provider.family.autoDispose?, ActionS final assets = ref.watch(assetsActionProvider(source)); final assetIds = assets.backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [assetsActionProvider]); class CleanupLocalAction extends AssetActionBuilder { const CleanupLocalAction({required super.source}); diff --git a/mobile/lib/presentation/actions/download.action.dart b/mobile/lib/presentation/actions/download.action.dart index 1303fa6b03..60a1395bcd 100644 --- a/mobile/lib/presentation/actions/download.action.dart +++ b/mobile/lib/presentation/actions/download.action.dart @@ -14,7 +14,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSou final assets = ref.watch(assetsActionProvider(source)); final remote = assets.remote().toList(growable: false); return remote.isEmpty ? null : remote; -}); +}, dependencies: [assetsActionProvider]); class DownloadAction extends AssetActionBuilder { const DownloadAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_asset.action.dart b/mobile/lib/presentation/actions/edit_asset.action.dart index d0a7e73122..9b11032df9 100644 --- a/mobile/lib/presentation/actions/edit_asset.action.dart +++ b/mobile/lib/presentation/actions/edit_asset.action.dart @@ -28,7 +28,7 @@ final _stateProvider = Provider.family.autoDispose(( final assets = ref.watch(ownedAssetsActionProvider(source)); return assets.where((asset) => asset.isEditable).singleOrNull; -}); +}, dependencies: [ownedAssetsActionProvider]); class EditAssetAction extends AssetActionBuilder { const EditAssetAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_datetime.action.dart b/mobile/lib/presentation/actions/edit_datetime.action.dart index a3c825c4db..31de23d1a7 100644 --- a/mobile/lib/presentation/actions/edit_datetime.action.dart +++ b/mobile/lib/presentation/actions/edit_datetime.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, } return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull); -}); +}, dependencies: [ownedAssetsActionProvider]); class EditDateTimeAction extends AssetActionBuilder { const EditDateTimeAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_location.action.dart b/mobile/lib/presentation/actions/edit_location.action.dart index f83a98099c..5ce74a0f40 100644 --- a/mobile/lib/presentation/actions/edit_location.action.dart +++ b/mobile/lib/presentation/actions/edit_location.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, } return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull); -}); +}, dependencies: [ownedAssetsActionProvider]); class EditLocationAction extends AssetActionBuilder { const EditLocationAction({required super.source}); diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 17c841b745..402d2f3833 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -18,7 +18,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final shouldFavorite = assets.favorite(isFavorite: false).isNotEmpty; final assetIds = assets.favorite(isFavorite: !shouldFavorite).map((asset) => asset.id).toList(growable: false); return (shouldFavorite: shouldFavorite, assetIds: assetIds); -}); +}, dependencies: [ownedAssetsActionProvider]); class FavoriteAction extends AssetActionBuilder { const FavoriteAction({required super.source}); diff --git a/mobile/lib/presentation/actions/lock.action.dart b/mobile/lib/presentation/actions/lock.action.dart index b7fd01ad18..3d090b712d 100644 --- a/mobile/lib/presentation/actions/lock.action.dart +++ b/mobile/lib/presentation/actions/lock.action.dart @@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, // Only locking has an on-device copy to clean up; unlocking leaves the device alone. localIds: shouldLock ? targets.map((asset) => asset.localId).nonNulls.toList(growable: false) : const [], ); -}); +}, dependencies: [ownedAssetsActionProvider]); class LockAction extends AssetActionBuilder { const LockAction({required super.source}); diff --git a/mobile/lib/presentation/actions/remove_from_album.action.dart b/mobile/lib/presentation/actions/remove_from_album.action.dart index 3d648a9cfd..9e2d9d582f 100644 --- a/mobile/lib/presentation/actions/remove_from_album.action.dart +++ b/mobile/lib/presentation/actions/remove_from_album.action.dart @@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(assetsActionProvider(source)); final assetIds = assets.remote().map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [assetsActionProvider]); class RemoveFromAlbumAction extends AssetActionBuilder { final String albumId; diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index 0a2f34abf8..0a1b707b39 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(ownedAssetsActionProvider(source)); final assetIds = assets.trashed().map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [ownedAssetsActionProvider]); class RestoreAction extends AssetActionBuilder { const RestoreAction({required super.source}); diff --git a/mobile/lib/presentation/actions/set_album_cover.action.dart b/mobile/lib/presentation/actions/set_album_cover.action.dart index 0c16c9a9db..d5ad367eed 100644 --- a/mobile/lib/presentation/actions/set_album_cover.action.dart +++ b/mobile/lib/presentation/actions/set_album_cover.action.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/error_handler.dart'; final _stateProvider = Provider.family.autoDispose((ref, source) { final assets = ref.watch(assetsActionProvider(source)); return assets.remote().map((asset) => asset.id).singleOrNull; -}); +}, dependencies: [assetsActionProvider]); class SetAlbumCoverAction extends AssetActionBuilder { final String albumId; diff --git a/mobile/lib/presentation/actions/share.action.dart b/mobile/lib/presentation/actions/share.action.dart index 4c36493265..3b8a6318de 100644 --- a/mobile/lib/presentation/actions/share.action.dart +++ b/mobile/lib/presentation/actions/share.action.dart @@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSourc final assets = ref.watch(assetsActionProvider(source)); final shareable = assets.toList(growable: false); return shareable.isEmpty ? null : shareable; -}); +}, dependencies: [assetsActionProvider]); class ShareAction extends AssetActionBuilder { const ShareAction({required super.source}); diff --git a/mobile/lib/presentation/actions/share_link.action.dart b/mobile/lib/presentation/actions/share_link.action.dart index 1966dd8811..4285105ed3 100644 --- a/mobile/lib/presentation/actions/share_link.action.dart +++ b/mobile/lib/presentation/actions/share_link.action.dart @@ -12,7 +12,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(assetsActionProvider(source)); final remoteIds = assets.remote().map((asset) => asset.id).toList(growable: false); return remoteIds.isEmpty ? null : remoteIds; -}); +}, dependencies: [assetsActionProvider]); class ShareLinkAction extends AssetActionBuilder { const ShareLinkAction({required super.source}); diff --git a/mobile/lib/presentation/actions/stack.action.dart b/mobile/lib/presentation/actions/stack.action.dart index 9697dc02be..5ab978a7e4 100644 --- a/mobile/lib/presentation/actions/stack.action.dart +++ b/mobile/lib/presentation/actions/stack.action.dart @@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, assetIds: assets.map((asset) => asset.id).toList(growable: false), stackIds: assets.map((asset) => asset.stackId).nonNulls.toList(growable: false), ); -}); +}, dependencies: [ownedAssetsActionProvider]); class StackAction extends AssetActionBuilder { const StackAction({required super.source}); diff --git a/mobile/lib/presentation/actions/tag.action.dart b/mobile/lib/presentation/actions/tag.action.dart index 749b23d150..978648708f 100644 --- a/mobile/lib/presentation/actions/tag.action.dart +++ b/mobile/lib/presentation/actions/tag.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(ownedAssetsActionProvider(source)); final assetIds = assets.map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [ownedAssetsActionProvider]); class TagAction extends AssetActionBuilder { const TagAction({required super.source}); diff --git a/mobile/lib/presentation/actions/upload.action.dart b/mobile/lib/presentation/actions/upload.action.dart index ceb35c8786..e52659beb7 100644 --- a/mobile/lib/presentation/actions/upload.action.dart +++ b/mobile/lib/presentation/actions/upload.action.dart @@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSour final assets = ref.watch(assetsActionProvider(source)); final local = assets.backedUp(isBackedUp: false).local().toList(growable: false); return local.isEmpty ? null : local; -}); +}, dependencies: [assetsActionProvider]); class UploadAction extends AssetActionBuilder { final bool showProgress; diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 2eb8ddc2b4..c6ed09360a 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -103,7 +103,8 @@ class WebsocketNotifier extends StateNotifier { socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2); socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1); socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); - socket.on('on_album_update', _handleAlbumUpdate); + socket.on('on_album_update', _handleRemoteChange); + socket.on('on_asset_stack_update', _handleRemoteChange); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { @@ -185,7 +186,7 @@ class WebsocketNotifier extends StateNotifier { unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); } - void _handleAlbumUpdate(dynamic _) { + void _handleRemoteChange(dynamic _) { unawaited(_ref.read(backgroundSyncProvider).syncRemote()); } diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 5b23890ef8..36ae8d087d 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -101,15 +101,18 @@ extension PumpPresentationWidget on WidgetTester { useFallbackTranslations: true, assetLoader: const CodegenLoader(), child: ProviderScope( - overrides: [...context.overrides, ...overrides], + overrides: context.overrides, child: Builder( - builder: (context) => MaterialApp( - debugShowCheckedModeBanner: false, - scaffoldMessengerKey: scaffoldMessengerKey, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: context.locale, - home: Scaffold(body: widget), + builder: (context) => ProviderScope( + overrides: overrides, + child: MaterialApp( + debugShowCheckedModeBanner: false, + scaffoldMessengerKey: scaffoldMessengerKey, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: Scaffold(body: widget), + ), ), ), ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5503526c1..44d920aaa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -952,8 +952,8 @@ importers: specifier: ^4.2.4 version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) '@testing-library/jest-dom': - specifier: ^6.4.2 - version: 6.9.1 + specifier: ^7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) '@testing-library/svelte': specifier: ^5.2.8 version: 5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.10) @@ -4995,9 +4995,11 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/jest-dom@7.0.0': + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' '@testing-library/svelte-core@1.1.3': resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} @@ -6805,6 +6807,7 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 cron@4.4.0: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} @@ -17751,9 +17754,10 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.1': + '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': dependencies: '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 aria-query: 5.3.1 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 3fc9fea2e3..1b5592d356 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -9,6 +9,7 @@ import semver from 'semver'; import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, + serverVersion, VECTOR_EXTENSIONS, VECTOR_INDEX_TABLES, VECTOR_VERSION_RANGE, @@ -382,6 +383,17 @@ export class DatabaseRepository { if (error) { this.logger.error(`Migrations failed: ${error}`); + + const missing = + error instanceof Error ? error.message.match(/previously executed migration (.+) is missing/u) : null; + if (missing) { + throw new Error( + `Migration "${missing[1]}" was already applied to this database but is not in this version of Immich (${serverVersion}). ` + + `This usually means the database was migrated by a newer version. Downgrades are not supported.`, + { cause: error }, + ); + } + throw error; } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 5a9a85f179..14527debba 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -317,6 +317,12 @@ describe(AssetMediaService.name, () => { ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.asset.remove).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: [file.originalPath, undefined] }, + }); + expect(mocks.event.emit).not.toHaveBeenCalled(); expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); expect(mocks.storage.utimes).not.toHaveBeenCalledWith( file.originalPath, diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 818bd5eb91..1bb9d1e5b6 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; -import { AuthSharedLink } from 'src/database'; +import { Asset, AuthSharedLink } from 'src/database'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, @@ -128,6 +128,7 @@ export class AssetMediaService extends BaseService { file: UploadFile, sidecarFile?: UploadFile, ): Promise { + let asset: Asset | undefined; try { await this.requireAccess({ auth, @@ -145,7 +146,7 @@ export class AssetMediaService extends BaseService { ); } - const asset = await this.assetRepository.create({ + asset = await this.assetRepository.create({ ownerId: auth.user.id, libraryId: null, @@ -215,6 +216,11 @@ export class AssetMediaService extends BaseService { return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } + // clean up the asset row if one was created + if (asset) { + await this.assetRepository.remove({ id: asset.id }); + } + this.logger.error(`Error uploading file ${error}`, error?.stack); throw error; } diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 029462a865..b947f3795a 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -54,6 +54,7 @@ describe(PartnerService.name, () => { const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(user2); mocks.partner.create.mockResolvedValue(getForPartner(partner)); await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined(); @@ -76,6 +77,19 @@ describe(PartnerService.name, () => { expect(mocks.partner.create).not.toHaveBeenCalled(); }); + + it('should throw an error when sharedWithId does not resolve to an existing (non-deleted) user', async () => { + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create({ id: user1.id }); + + mocks.partner.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); + + await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.partner.create).not.toHaveBeenCalled(); + }); }); describe('remove', () => { diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index cc950edb5b..26d7701077 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -16,6 +16,12 @@ export class PartnerService extends BaseService { throw new BadRequestException(`Partner already exists`); } + const user = await this.userRepository.get(sharedWithId, {}); + if (!user) { + this.logger.debug('Partner creation failed: user not found'); + throw new BadRequestException('Invalid user'); + } + const partner = await this.partnerRepository.create(partnerId); return this.mapPartner(partner, PartnerDirection.SharedBy); } diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index 1ad40ecca7..8bd65af73f 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -406,6 +406,101 @@ describe('core plugin', () => { }); }); + describe('assetFileFilter', () => { + it('should match assets case-insensitively', async () => { + const { user } = await ctx.newUser(); + const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([ + ctx.newAsset({ ownerId: user.id, originalFileName: 'exampleFile.png' }), + ctx.newAsset({ ownerId: user.id, originalFileName: 'anotherfile.jpg' }), + ]); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [ + { + method: 'immich-plugin-core#assetFileFilter', + config: { matchType: 'contains', pattern: 'File' }, + }, + { + method: 'immich-plugin-core#assetFavorite', + }, + ], + }); + + await expect( + ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }), + ).resolves.toBeUndefined(); + await expect( + ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }), + ).resolves.toBeUndefined(); + await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true }); + await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true }); + }); + + it('should match assets by regex', async () => { + const { user } = await ctx.newUser(); + const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([ + ctx.newAsset({ ownerId: user.id, originalFileName: 'exampleFile.png' }), + ctx.newAsset({ ownerId: user.id, originalFileName: 'anotherfile.jpg' }), + ]); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [ + { + method: 'immich-plugin-core#assetFileFilter', + config: { matchType: 'regex', pattern: '.+png' }, + }, + { + method: 'immich-plugin-core#assetFavorite', + }, + ], + }); + + await expect( + ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }), + ).resolves.toBeUndefined(); + await expect( + ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }), + ).resolves.toBeUndefined(); + await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true }); + await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false }); + }); + + it('should filter assets by path if specified', async () => { + const { user } = await ctx.newUser(); + const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([ + ctx.newAsset({ ownerId: user.id, originalPath: '/library/folder/file1.png' }), + ctx.newAsset({ ownerId: user.id, originalPath: '/library/file2.png' }), + ]); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [ + { + method: 'immich-plugin-core#assetFileFilter', + config: { matchType: 'contains', pattern: 'folder', usePath: true }, + }, + { + method: 'immich-plugin-core#assetFavorite', + }, + ], + }); + + await expect( + ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }), + ).resolves.toBeUndefined(); + await expect( + ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }), + ).resolves.toBeUndefined(); + await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true }); + await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false }); + }); + }); + describe('assetTypeFilter', () => { it('should favorite asset if it is a video', async () => { const { user } = await ctx.newUser(); diff --git a/web/package.json b/web/package.json index aeaeb3ee20..338e2f5abe 100644 --- a/web/package.json +++ b/web/package.json @@ -80,7 +80,7 @@ "@sveltejs/kit": "^2.56.1", "@sveltejs/vite-plugin-svelte": "7.2.0", "@tailwindcss/vite": "^4.2.4", - "@testing-library/jest-dom": "^6.4.2", + "@testing-library/jest-dom": "^7.0.0", "@testing-library/svelte": "^5.2.8", "@testing-library/user-event": "^14.5.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", diff --git a/web/src/lib/components/SchemaAlbumPicker.svelte b/web/src/lib/components/SchemaAlbumPicker.svelte index b7eb9dd7dd..e4eb271a3d 100644 --- a/web/src/lib/components/SchemaAlbumPicker.svelte +++ b/web/src/lib/components/SchemaAlbumPicker.svelte @@ -14,7 +14,7 @@ let { array, label, description, albumIds = $bindable([]) }: Props = $props(); const onAlbums = async () => { - const albums = await modalManager.show(AlbumPickerModal); + const albums = await modalManager.show(AlbumPickerModal, {}); if (!albums || albums.length === 0) { return; } diff --git a/web/src/lib/components/asset-viewer/AlbumListItem.svelte b/web/src/lib/components/asset-viewer/AlbumListItem.svelte index af40deb27a..3d4be59a2c 100644 --- a/web/src/lib/components/asset-viewer/AlbumListItem.svelte +++ b/web/src/lib/components/asset-viewer/AlbumListItem.svelte @@ -40,6 +40,9 @@ const albumNameArray: string[] = $derived.by(() => { let { albumName } = album; let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery)); + if (findIndex === -1) { + return [albumName, '', '']; + } let findLength = searchQuery.length; return [ albumName.slice(0, findIndex), diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts index 669ce22f85..c7c3413b84 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -108,6 +108,19 @@ describe('Album Modal', () => { ]); }); + it('search matches on description as well as name', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Vacances 2019', description: 'Crete' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('Crete', [], [holidayAlbum, constructionAlbum], -1, []); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('ALBUMS'), + createAlbumRow(holidayAlbum, false), + ]); + }); + it('selection can select new album row', () => { const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts index ad96b27c06..5d4e46a063 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -46,10 +46,14 @@ export class AlbumModalRowConverter { const recentAlbumsToShow = search.length === 0 ? recentAlbums : []; const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }]; + const normalizedSearch = normalizeSearchString(search); const filteredAlbums = sortAlbums( search.length > 0 && albums.length > 0 ? albums.filter((album) => { - return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); + return ( + normalizeSearchString(album.albumName).includes(normalizedSearch) || + normalizeSearchString(album.description).includes(normalizedSearch) + ); }) : albums, { sortBy: this.sortBy, orderBy: this.orderBy }, diff --git a/web/src/lib/modals/AlbumPickerModal.spec.ts b/web/src/lib/modals/AlbumPickerModal.spec.ts new file mode 100644 index 0000000000..4245f3e17f --- /dev/null +++ b/web/src/lib/modals/AlbumPickerModal.spec.ts @@ -0,0 +1,58 @@ +import { render, screen, waitFor } from '@testing-library/svelte'; +import { init, register, waitLocale } from 'svelte-i18n'; +import { getAnimateMock } from '$lib/__mocks__/animate.mock'; +import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; +import { sdkMock } from '$lib/__mocks__/sdk.mock'; +import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock'; +import AlbumPickerModal from './AlbumPickerModal.svelte'; + +describe('AlbumPickerModal component', () => { + const onClose = vi.fn(); + + beforeAll(async () => { + await init({ fallbackLocale: 'en-US' }); + register('en-US', () => import('$i18n/en.json')); + await waitLocale('en-US'); + }); + + beforeEach(() => { + vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); + vi.stubGlobal('visualViewport', getVisualViewportMock()); + vi.resetAllMocks(); + Element.prototype.animate = getAnimateMock(); + }); + + afterAll(async () => { + await waitFor(() => { + expect(document.body.style.pointerEvents).not.toBe('none'); + }); + }); + + it('shows the singular selection count title when selectedItemsCount is 1', async () => { + // Called by onMount() + sdkMock.getAllAlbums.mockResolvedValueOnce([]); + + render(AlbumPickerModal, { props: { onClose, selectedItemsCount: 1 } }); + + expect(await screen.findByText('Add 1 item to album')).toBeInTheDocument(); + expect(screen.queryByText('Select albums')).not.toBeInTheDocument(); + }); + + it('shows the plural selection count title when selectedItemsCount is greater than 1', async () => { + sdkMock.getAllAlbums.mockResolvedValueOnce([]); + + render(AlbumPickerModal, { props: { onClose, selectedItemsCount: 3 } }); + + expect(await screen.findByText('Add 3 items to album')).toBeInTheDocument(); + expect(screen.queryByText('Select albums')).not.toBeInTheDocument(); + }); + + it('shows the generic title when selectedItemsCount is not provided', async () => { + sdkMock.getAllAlbums.mockResolvedValueOnce([]); + + render(AlbumPickerModal, { props: { onClose } }); + + expect(await screen.findByText('Select albums')).toBeInTheDocument(); + expect(screen.queryByText('Add 1 item to album')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 561deeac23..19430e271c 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -23,9 +23,10 @@ type Props = { onClose: (albums?: AlbumResponseDto[]) => void; + selectedItemsCount?: number; }; - let { onClose }: Props = $props(); + let { onClose, selectedItemsCount }: Props = $props(); onMount(async () => { albums = await getAllAlbums({}); @@ -147,9 +148,15 @@ } } }; + + const title = $derived( + selectedItemsCount === undefined + ? $t('select_albums') + : $t('add_to_album_item_count', { values: { count: selectedItemsCount } }), + ); - +
{#if loading} diff --git a/web/src/lib/modals/AssetAddToAlbumModal.svelte b/web/src/lib/modals/AssetAddToAlbumModal.svelte index b35c125d08..7259dd8245 100644 --- a/web/src/lib/modals/AssetAddToAlbumModal.svelte +++ b/web/src/lib/modals/AssetAddToAlbumModal.svelte @@ -24,4 +24,4 @@ }; - + diff --git a/web/src/lib/modals/PersonMergeSuggestionModal.svelte b/web/src/lib/modals/PersonMergeSuggestionModal.svelte index 2f799c2939..94006ed5e7 100644 --- a/web/src/lib/modals/PersonMergeSuggestionModal.svelte +++ b/web/src/lib/modals/PersonMergeSuggestionModal.svelte @@ -63,13 +63,16 @@
{#if !choosePersonToMerge}
- + + {#key personToMerge.id} + + {/key}
@@ -101,14 +104,16 @@ } }} > - 0} - circle - shadow - url={getPeopleThumbnailUrl(personToBeMergedInto)} - altText={personToBeMergedInto.name} - widthStyle="100%" - /> + {#key personToBeMergedInto.id} + 0} + circle + shadow + url={getPeopleThumbnailUrl(personToBeMergedInto)} + altText={personToBeMergedInto.name} + widthStyle="100%" + /> + {/key} {:else}
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/FaceThumbnail.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/FaceThumbnail.svelte index 4a594ddce1..8f2f82e9b5 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/FaceThumbnail.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/FaceThumbnail.svelte @@ -40,7 +40,10 @@ class:dark:border-immich-dark-primary={border} class:border-immich-primary={border} > - + + {#key person.id} + + {/key}