mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge branch 'main' into feat/album-asset-workflow-trigger
This commit is contained in:
commit
61620e6c27
41 changed files with 307 additions and 67 deletions
|
|
@ -1,7 +0,0 @@
|
|||
merge_queue:
|
||||
status_comments: outcomes
|
||||
|
||||
queue_rules:
|
||||
- name: default
|
||||
batch_size: 3
|
||||
batch_max_wait_time: 2 min
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const Map<String, Locale> 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'),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ final assetsActionProvider = Provider.family.autoDispose<AssetFilter<BaseAsset>,
|
|||
null => const <BaseAsset>{},
|
||||
},
|
||||
}),
|
||||
dependencies: [multiSelectProvider],
|
||||
);
|
||||
|
||||
final clearSelectionProvider = Provider.family.autoDispose<VoidCallback, ActionSource>((ref, source) {
|
||||
|
|
@ -41,10 +42,11 @@ final clearSelectionProvider = Provider.family.autoDispose<VoidCallback, ActionS
|
|||
}
|
||||
|
||||
return () {};
|
||||
});
|
||||
}, dependencies: [multiSelectProvider]);
|
||||
|
||||
final ownedAssetsActionProvider = Provider.family.autoDispose<AssetFilter<RemoteAsset>, ActionSource>(
|
||||
(ref, source) => ref.watch(assetsActionProvider(source)).owned(ref.watch(authUserProvider).id),
|
||||
dependencies: [assetsActionProvider],
|
||||
);
|
||||
|
||||
abstract class AssetActionBuilder extends ActionBuilder {
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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<List<String>?, 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});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ final _stateProvider = Provider.family.autoDispose<List<RemoteAsset>?, 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});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ final _stateProvider = Provider.family.autoDispose<RemoteAsset?, ActionSource>((
|
|||
|
||||
final assets = ref.watch(ownedAssetsActionProvider(source));
|
||||
return assets.where((asset) => asset.isEditable).singleOrNull;
|
||||
});
|
||||
}, dependencies: [ownedAssetsActionProvider]);
|
||||
|
||||
class EditAssetAction extends AssetActionBuilder {
|
||||
const EditAssetAction({required super.source});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, 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;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, 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});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/error_handler.dart';
|
|||
final _stateProvider = Provider.family.autoDispose<String?, ActionSource>((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;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose<List<BaseAsset>?, 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});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, 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});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, 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});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose<List<LocalAsset>?, 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;
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||
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<WebsocketState> {
|
|||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
|
||||
}
|
||||
|
||||
void _handleAlbumUpdate(dynamic _) {
|
||||
void _handleRemoteChange(dynamic _) {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncRemote());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<AssetMediaResponseDto> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
58
web/src/lib/modals/AlbumPickerModal.spec.ts
Normal file
58
web/src/lib/modals/AlbumPickerModal.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } }),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Modal title={$t('add_to_album')} {onClose} size="small">
|
||||
<Modal {title} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<div class="mb-2 flex max-h-100 flex-col">
|
||||
{#if loading}
|
||||
|
|
|
|||
|
|
@ -24,4 +24,4 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
<AlbumPickerModal onClose={handleClose} />
|
||||
<AlbumPickerModal selectedItemsCount={assetIds.length} onClose={handleClose} />
|
||||
|
|
|
|||
|
|
@ -63,13 +63,16 @@
|
|||
<div class="flex items-center justify-center gap-2 py-4 md:h-36">
|
||||
{#if !choosePersonToMerge}
|
||||
<div class="flex size-20 items-center px-1 md:size-24 md:px-2">
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToMerge)}
|
||||
altText={personToMerge.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
<!-- Trigger a re-render on person change as <Image> captures only the first src -->
|
||||
{#key personToMerge.id}
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToMerge)}
|
||||
altText={personToMerge.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-rows-3">
|
||||
|
|
@ -101,14 +104,16 @@
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ImageThumbnail
|
||||
border={potentialMergePeople.length > 0}
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToBeMergedInto)}
|
||||
altText={personToBeMergedInto.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
{#key personToBeMergedInto.id}
|
||||
<ImageThumbnail
|
||||
border={potentialMergePeople.length > 0}
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToBeMergedInto)}
|
||||
altText={personToBeMergedInto.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
{/key}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="grid w-full grid-cols-1 gap-2">
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@
|
|||
class:dark:border-immich-dark-primary={border}
|
||||
class:border-immich-primary={border}
|
||||
>
|
||||
<ImageThumbnail {circle} url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" shadow />
|
||||
<!-- Trigger a re-render on person change as <Image> captures only the first src -->
|
||||
{#key person.id}
|
||||
<ImageThumbnail {circle} url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" shadow />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue