1
0
forked from Cutlery/immich

Merge branch 'main' into fix/edit-faces-notification

This commit is contained in:
martabal 2024-03-19 18:37:43 +01:00
commit 86b200e870
No known key found for this signature in database
GPG Key ID: C00196E3148A52BD
57 changed files with 1393 additions and 697 deletions

View File

@ -11,7 +11,7 @@ Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including: Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) - [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/) - [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/)
- [Okta](https://www.okta.com/openid-connect/) - [Okta](https://www.okta.com/openid-connect/)
- [Google](https://developers.google.com/identity/openid-connect/openid-connect) - [Google](https://developers.google.com/identity/openid-connect/openid-connect)

6
e2e/package-lock.json generated
View File

@ -1158,9 +1158,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.25", "version": "20.11.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz",
"integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==", "integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"

View File

@ -27,7 +27,7 @@ describe('/library', () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, userDto.user1); user = await utils.userSetup(admin.accessToken, userDto.user1);
library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External });
websocket = await utils.connectWebsocket(admin.accessToken); websocket = await utils.connectWebsocket(admin.accessToken);
}); });
@ -82,7 +82,7 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.send({ type: LibraryType.External }); .send({ ownerId: admin.userId, type: LibraryType.External });
expect(status).toBe(403); expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
@ -92,7 +92,7 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.External }); .send({ ownerId: admin.userId, type: LibraryType.External });
expect(status).toBe(201); expect(status).toBe(201);
expect(body).toEqual( expect(body).toEqual(
@ -113,6 +113,7 @@ describe('/library', () => {
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ .send({
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
name: 'My Awesome Library', name: 'My Awesome Library',
importPaths: ['/path/to/import'], importPaths: ['/path/to/import'],
@ -133,6 +134,7 @@ describe('/library', () => {
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ .send({
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
name: 'My Awesome Library', name: 'My Awesome Library',
importPaths: ['/path', '/path'], importPaths: ['/path', '/path'],
@ -148,6 +150,7 @@ describe('/library', () => {
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ .send({
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
name: 'My Awesome Library', name: 'My Awesome Library',
importPaths: ['/path/to/import'], importPaths: ['/path/to/import'],
@ -162,7 +165,7 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload }); .send({ ownerId: admin.userId, type: LibraryType.Upload });
expect(status).toBe(201); expect(status).toBe(201);
expect(body).toEqual( expect(body).toEqual(
@ -182,7 +185,7 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, name: 'My Awesome Library' }); .send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' });
expect(status).toBe(201); expect(status).toBe(201);
expect(body).toEqual( expect(body).toEqual(
@ -196,7 +199,7 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] }); .send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths')); expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
@ -206,7 +209,7 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); .send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns')); expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
@ -330,7 +333,10 @@ describe('/library', () => {
}); });
it('should get library by id', async () => { it('should get library by id', async () => {
const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
});
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/library/${library.id}`) .get(`/library/${library.id}`)
@ -386,7 +392,10 @@ describe('/library', () => {
}); });
it('should delete an external library', async () => { it('should delete an external library', async () => {
const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External,
});
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/library/${library.id}`) .delete(`/library/${library.id}`)
@ -407,6 +416,7 @@ describe('/library', () => {
it('should delete an external library with assets', async () => { it('should delete an external library with assets', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -455,6 +465,7 @@ describe('/library', () => {
it('should not scan an upload library', async () => { it('should not scan an upload library', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.Upload, type: LibraryType.Upload,
}); });
@ -468,6 +479,7 @@ describe('/library', () => {
it('should scan external library', async () => { it('should scan external library', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`], importPaths: [`${testAssetDirInternal}/temp/directoryA`],
}); });
@ -483,6 +495,7 @@ describe('/library', () => {
it('should scan external library with exclusion pattern', async () => { it('should scan external library with exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
exclusionPatterns: ['**/directoryA'], exclusionPatterns: ['**/directoryA'],
@ -499,6 +512,7 @@ describe('/library', () => {
it('should scan multiple import paths', async () => { it('should scan multiple import paths', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
}); });
@ -515,6 +529,7 @@ describe('/library', () => {
it('should pick up new files', async () => { it('should pick up new files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.External, type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });

View File

@ -6,7 +6,7 @@ ip_address=$(hostname -I | awk '{print $1}')
create_immich_directory() { create_immich_directory() {
echo "Creating Immich directory..." echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data mkdir -p ./immich-app
cd ./immich-app || exit cd ./immich-app || exit
} }
@ -20,21 +20,6 @@ download_dot_env_file() {
curl -L https://github.com/immich-app/immich/releases/latest/download/example.env -o ./.env >/dev/null 2>&1 curl -L https://github.com/immich-app/immich/releases/latest/download/example.env -o ./.env >/dev/null 2>&1
} }
replace_env_value() {
KERNEL="$(uname -s | tr '[:upper:]' '[:lower:]')"
if [ "$KERNEL" = "darwin" ]; then
sed -i '' "s|$1=.*|$1=$2|" ./.env
else
sed -i "s|$1=.*|$1=$2|" ./.env
fi
}
populate_upload_location() {
echo "Populating default UPLOAD_LOCATION value..."
upload_location=$(pwd)/immich-data
replace_env_value "UPLOAD_LOCATION" "$upload_location"
}
start_docker_compose() { start_docker_compose() {
echo "Starting Immich's docker containers" echo "Starting Immich's docker containers"
@ -59,7 +44,6 @@ start_docker_compose() {
show_friendly_message() { show_friendly_message() {
echo "Successfully deployed Immich!" echo "Successfully deployed Immich!"
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api" echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
echo "The library location is $upload_location"
echo "---------------------------------------------------" echo "---------------------------------------------------"
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc. echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
@ -75,5 +59,4 @@ show_friendly_message() {
create_immich_directory create_immich_directory
download_docker_compose_file download_docker_compose_file
download_dot_env_file download_dot_env_file
populate_upload_location
start_docker_compose start_docker_compose

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -22,8 +22,11 @@ class ExifPeople extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final peopleProvider = final peopleProvider =
ref.watch(assetPeopleNotifierProvider(asset).notifier); ref.watch(assetPeopleNotifierProvider(asset).notifier);
final people = ref.watch(assetPeopleNotifierProvider(asset)); final people = ref
final double imageSize = math.min(context.width / 3, 150); .watch(assetPeopleNotifierProvider(asset))
.value
?.where((p) => !p.isHidden);
final double imageSize = math.min(context.width / 3, 120);
showPersonNameEditModel( showPersonNameEditModel(
String personId, String personId,
@ -40,14 +43,13 @@ class ExifPeople extends ConsumerWidget {
}); });
} }
if (people.value?.isEmpty ?? true) { if (people?.isEmpty ?? true) {
// Empty list or loading // Empty list or loading
return Container(); return Container();
} }
final curatedPeople = people.value final curatedPeople =
?.map((p) => CuratedContent(id: p.id, label: p.name)) people?.map((p) => CuratedContent(id: p.id, label: p.name)).toList() ??
.toList() ??
[]; [];
return Column( return Column(

View File

@ -5,11 +5,9 @@ import 'package:photo_manager/photo_manager.dart';
class AvailableAlbum { class AvailableAlbum {
final AssetPathEntity albumEntity; final AssetPathEntity albumEntity;
final DateTime? lastBackup; final DateTime? lastBackup;
final Uint8List? thumbnailData;
AvailableAlbum({ AvailableAlbum({
required this.albumEntity, required this.albumEntity,
this.lastBackup, this.lastBackup,
this.thumbnailData,
}); });
AvailableAlbum copyWith({ AvailableAlbum copyWith({
@ -20,7 +18,6 @@ class AvailableAlbum {
return AvailableAlbum( return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity, albumEntity: albumEntity ?? this.albumEntity,
lastBackup: lastBackup ?? this.lastBackup, lastBackup: lastBackup ?? this.lastBackup,
thumbnailData: thumbnailData ?? this.thumbnailData,
); );
} }
@ -34,7 +31,7 @@ class AvailableAlbum {
@override @override
String toString() => String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)'; 'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View File

@ -234,34 +234,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
for (AssetPathEntity album in albums) { for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
final assetCountInAlbum = await album.assetCountAsync;
if (assetCountInAlbum > 0) {
final assetList = await album.getAssetListPaged(page: 0, size: 1);
// Even though we check assetCountInAlbum to make sure that there are assets in album
// The `getAssetListPaged` method still return empty list and cause not assets get rendered
if (assetList.isEmpty) {
continue;
}
final thumbnailAsset = assetList.first;
try {
final thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
} catch (e, stack) {
log.severe(
"Failed to get thumbnail for album ${album.name}",
e,
stack,
);
}
availableAlbums.add(availableAlbum); availableAlbums.add(availableAlbum);
albumMap[album.id] = album; albumMap[album.id] = album;
} }
}
state = state.copyWith(availableAlbums: availableAlbums); state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums = final List<BackupAlbum> excludedBackupAlbums =

View File

@ -11,17 +11,16 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
class AlbumInfoCard extends HookConsumerWidget { class AlbumInfoCard extends HookConsumerWidget {
final Uint8List? imageData; final AvailableAlbum album;
final AvailableAlbum albumInfo;
const AlbumInfoCard({super.key, this.imageData, required this.albumInfo}); const AlbumInfoCard({super.key, required this.album});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected = final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo); ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded = final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo); ref.watch(backupProvider).excludedBackupAlbums.contains(album);
final isDarkTheme = context.isDarkTheme; final isDarkTheme = context.isDarkTheme;
ColorFilter selectedFilter = ColorFilter.mode( ColorFilter selectedFilter = ColorFilter.mode(
@ -82,9 +81,9 @@ class AlbumInfoCard extends HookConsumerWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
if (isSelected) { if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo); ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else { } else {
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo); ref.read(backupProvider.notifier).addAlbumForBackup(album);
} }
}, },
onDoubleTap: () { onDoubleTap: () {
@ -92,13 +91,11 @@ class AlbumInfoCard extends HookConsumerWidget {
if (isExcluded) { if (isExcluded) {
// Remove from exclude album list // Remove from exclude album list
ref ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
.read(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else { } else {
// Add to exclude album list // Add to exclude album list
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') { if (album.id == 'isAll' || album.name == 'Recents') {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'Cannot exclude album contains all assets', msg: 'Cannot exclude album contains all assets',
@ -108,9 +105,7 @@ class AlbumInfoCard extends HookConsumerWidget {
return; return;
} }
ref ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
.read(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
} }
}, },
child: Card( child: Card(
@ -136,14 +131,12 @@ class AlbumInfoCard extends HookConsumerWidget {
children: [ children: [
ColorFiltered( ColorFiltered(
colorFilter: buildImageFilter(), colorFilter: buildImageFilter(),
child: Image( child: const Image(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
image: imageData != null image: AssetImage(
? MemoryImage(imageData!)
: const AssetImage(
'assets/immich-logo.png', 'assets/immich-logo.png',
) as ImageProvider, ),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
), ),
@ -168,7 +161,7 @@ class AlbumInfoCard extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
albumInfo.name, album.name,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: context.primaryColor, color: context.primaryColor,
@ -182,7 +175,7 @@ class AlbumInfoCard extends HookConsumerWidget {
if (snapshot.hasData) { if (snapshot.hasData) {
return Text( return Text(
snapshot.data.toString() + snapshot.data.toString() +
(albumInfo.isAll (album.isAll
? " (${'backup_all'.tr()})" ? " (${'backup_all'.tr()})"
: ""), : ""),
style: TextStyle( style: TextStyle(
@ -193,7 +186,7 @@ class AlbumInfoCard extends HookConsumerWidget {
} }
return const Text("0"); return const Text("0");
}), }),
future: albumInfo.assetCount, future: album.assetCount,
), ),
), ),
], ],
@ -202,7 +195,7 @@ class AlbumInfoCard extends HookConsumerWidget {
IconButton( IconButton(
onPressed: () { onPressed: () {
context.pushRoute( context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity), AlbumPreviewRoute(album: album.albumEntity),
); );
}, },
icon: Icon( icon: Icon(

View File

@ -11,47 +11,26 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
class AlbumInfoListTile extends HookConsumerWidget { class AlbumInfoListTile extends HookConsumerWidget {
final Uint8List? imageData; final AvailableAlbum album;
final AvailableAlbum albumInfo;
const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo}); const AlbumInfoListTile({super.key, required this.album});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected = final bool isSelected =
ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo); ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded = final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo); ref.watch(backupProvider).excludedBackupAlbums.contains(album);
ColorFilter selectedFilter = ColorFilter.mode(
context.primaryColor.withAlpha(100),
BlendMode.darken,
);
ColorFilter excludedFilter =
ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter =
const ColorFilter.mode(Colors.black, BlendMode.color);
var assetCount = useState(0); var assetCount = useState(0);
useEffect( useEffect(
() { () {
albumInfo.assetCount.then((value) => assetCount.value = value); album.assetCount.then((value) => assetCount.value = value);
return null; return null;
}, },
[albumInfo], [album],
); );
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
buildTileColor() { buildTileColor() {
if (isSelected) { if (isSelected) {
return context.isDarkTheme return context.isDarkTheme
@ -66,19 +45,38 @@ class AlbumInfoListTile extends HookConsumerWidget {
} }
} }
buildIcon() {
if (isSelected) {
return const Icon(
Icons.check_circle_rounded,
color: Colors.green,
);
}
if (isExcluded) {
return const Icon(
Icons.remove_circle_rounded,
color: Colors.red,
);
}
return Icon(
Icons.circle,
color: context.isDarkTheme ? Colors.grey[400] : Colors.black45,
);
}
return GestureDetector( return GestureDetector(
onDoubleTap: () { onDoubleTap: () {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
if (isExcluded) { if (isExcluded) {
// Remove from exclude album list // Remove from exclude album list
ref ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
.read(backupProvider.notifier)
.removeExcludedAlbumForBackup(albumInfo);
} else { } else {
// Add to exclude album list // Add to exclude album list
if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') { if (album.id == 'isAll' || album.name == 'Recents') {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'Cannot exclude album contains all assets', msg: 'Cannot exclude album contains all assets',
@ -88,9 +86,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
return; return;
} }
ref ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
.read(backupProvider.notifier)
.addExcludedAlbumForBackup(albumInfo);
} }
}, },
child: ListTile( child: ListTile(
@ -99,33 +95,14 @@ class AlbumInfoListTile extends HookConsumerWidget {
onTap: () { onTap: () {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
if (isSelected) { if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo); ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else { } else {
ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo); ref.read(backupProvider.notifier).addAlbumForBackup(album);
} }
}, },
leading: ClipRRect( leading: buildIcon(),
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 80,
width: 80,
child: ColorFiltered(
colorFilter: buildImageFilter(),
child: Image(
width: double.infinity,
height: double.infinity,
image: imageData != null
? MemoryImage(imageData!)
: const AssetImage(
'assets/immich-logo.png',
) as ImageProvider,
fit: BoxFit.cover,
),
),
),
),
title: Text( title: Text(
albumInfo.name, album.name,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -135,7 +112,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
trailing: IconButton( trailing: IconButton(
onPressed: () { onPressed: () {
context.pushRoute( context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity), AlbumPreviewRoute(album: album.albumEntity),
); );
}, },
icon: Icon( icon: Icon(

View File

@ -43,10 +43,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
((context, index) { ((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoListTile( return AlbumInfoListTile(
imageData: thumbnailData, album: albums[index],
albumInfo: albums[index],
); );
}), }),
childCount: albums.length, childCount: albums.length,
@ -74,10 +72,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
), ),
itemCount: albums.length, itemCount: albums.length,
itemBuilder: ((context, index) { itemBuilder: ((context, index) {
var thumbnailData = albums[index].thumbnailData;
return AlbumInfoCard( return AlbumInfoCard(
imageData: thumbnailData, album: albums[index],
albumInfo: albums[index],
); );
}), }),
), ),

View File

@ -26,7 +26,7 @@ class BackupControllerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider); BackUpState backupState = ref.watch(backupProvider);
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty; final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
final didGetBackupInfo = useState(false);
bool hasExclusiveAccess = bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground; backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length - bool shouldBackup = backupState.allUniqueAssets.length -
@ -38,11 +38,6 @@ class BackupControllerPage extends HookConsumerWidget {
useEffect( useEffect(
() { () {
if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
ref.watch(backupProvider.notifier).getBackupInfo();
}
// Update the background settings information just to make sure we // Update the background settings information just to make sure we
// have the latest, since the platform channel will not update // have the latest, since the platform channel will not update
// automatically // automatically
@ -58,6 +53,18 @@ class BackupControllerPage extends HookConsumerWidget {
[], [],
); );
useEffect(
() {
if (backupState.backupProgress == BackUpProgressEnum.idle &&
!didGetBackupInfo.value) {
ref.watch(backupProvider.notifier).getBackupInfo();
didGetBackupInfo.value = true;
}
return null;
},
[backupState.backupProgress],
);
Widget buildSelectedAlbumName() { Widget buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr(); var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums; var albums = ref.watch(backupProvider).selectedBackupAlbums;
@ -235,6 +242,15 @@ class BackupControllerPage extends HookConsumerWidget {
); );
} }
buildLoadingIndicator() {
return const Padding(
padding: EdgeInsets.only(top: 42.0),
child: Center(
child: CircularProgressIndicator(),
),
);
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
@ -297,7 +313,10 @@ class BackupControllerPage extends HookConsumerWidget {
if (!hasExclusiveAccess) buildBackgroundBackupInfo(), if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(), buildBackupButton(),
] ]
: [buildFolderSelectionTile()], : [
buildFolderSelectionTile(),
if (!didGetBackupInfo.value) buildLoadingIndicator(),
],
), ),
), ),
); );

View File

@ -0,0 +1,223 @@
// ignore_for_file: library_private_types_in_public_api
// Based on https://stackoverflow.com/a/52625182
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class AssetDragRegion extends StatefulWidget {
final Widget child;
final void Function(AssetIndex valueKey)? onStart;
final void Function(AssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const AssetDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _AssetDragRegionState();
}
class _AssetDragRegionState extends State<AssetDragRegion> {
late AssetIndex? assetUnderPointer;
late AssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
recognizer.onLongPressCancel = _onLongPressEnd;
}
AssetIndex? _getValueKeyAtPositon(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path
.firstWhereOrNull((hit) => hit.target is _AssetIndexProxy)
?.target as _AssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null &&
(topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPositon(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
// ignore: prefer-single-widget-per-file
class AssetIndexWrapper extends SingleChildRenderObjectWidget {
final int rowIndex;
final int sectionIndex;
const AssetIndexWrapper({
required Widget super.child,
required this.rowIndex,
required this.sectionIndex,
super.key,
});
@override
_AssetIndexProxy createRenderObject(BuildContext context) {
return _AssetIndexProxy(
index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
_AssetIndexProxy renderObject,
) {
renderObject.index =
AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex);
}
}
class _AssetIndexProxy extends RenderProxyBox {
AssetIndex index;
_AssetIndexProxy({
required this.index,
});
}
class AssetIndex {
final int rowIndex;
final int sectionIndex;
const AssetIndex({
required this.rowIndex,
required this.sectionIndex,
});
@override
bool operator ==(covariant AssetIndex other) {
if (identical(this, other)) return true;
return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex;
}
@override
int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode;
}

View File

@ -5,12 +5,15 @@ import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget {
class ImmichAssetGridViewState extends State<ImmichAssetGridView> { class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final ItemScrollController _itemScrollController = ItemScrollController(); final ItemScrollController _itemScrollController = ItemScrollController();
final ScrollOffsetController _scrollOffsetController =
ScrollOffsetController();
final ItemPositionsListener _itemPositionsListener = final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create(); ItemPositionsListener.create();
@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final Set<Asset> _selectedAssets = final Set<Asset> _selectedAssets =
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
bool _dragging = false;
int? _dragAnchorAssetIndex;
int? _dragAnchorSectionIndex;
final Set<Asset> _draggedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
Set<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets); return Set.from(_selectedAssets);
} }
@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _selectAssets(List<Asset> assets) { void _selectAssets(List<Asset> assets) {
setState(() { setState(() {
if (_dragging) {
_draggedAssets.addAll(assets);
}
_selectedAssets.addAll(assets); _selectedAssets.addAll(assets);
_callSelectionListener(true); _callSelectionListener(true);
}); });
} }
void _deselectAssets(List<Asset> assets) { void _deselectAssets(List<Asset> assets) {
setState(() { final assetsToDeselect = assets.where(
_selectedAssets.removeAll(
assets.where(
(a) => (a) =>
widget.canDeselect || widget.canDeselect ||
!(widget.preselectedAssets?.contains(a) ?? false), !(widget.preselectedAssets?.contains(a) ?? false),
),
); );
setState(() {
_selectedAssets.removeAll(assetsToDeselect);
if (_dragging) {
_draggedAssets.removeAll(assetsToDeselect);
}
_callSelectionListener(_selectedAssets.isNotEmpty); _callSelectionListener(_selectedAssets.isNotEmpty);
}); });
} }
@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _deselectAll() { void _deselectAll() {
setState(() { setState(() {
_selectedAssets.clear(); _selectedAssets.clear();
_dragAnchorAssetIndex = null;
_dragAnchorSectionIndex = null;
_draggedAssets.clear();
_dragging = false;
if (!widget.canDeselect && if (!widget.canDeselect &&
widget.preselectedAssets != null && widget.preselectedAssets != null &&
widget.preselectedAssets!.isNotEmpty) { widget.preselectedAssets!.isNotEmpty) {
@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
showStorageIndicator: widget.showStorageIndicator, showStorageIndicator: widget.showStorageIndicator,
selectedAssets: _selectedAssets, selectedAssets: _selectedAssets,
selectionActive: widget.selectionActive, selectionActive: widget.selectionActive,
sectionIndex: index,
section: section, section: section,
margin: widget.margin, margin: widget.margin,
renderList: widget.renderList, renderList: widget.renderList,
@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
itemBuilder: _itemBuilder, itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener, itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController, itemScrollController: _itemScrollController,
scrollOffsetController: _scrollOffsetController,
itemCount: widget.renderList.elements.length + itemCount: widget.renderList.elements.length +
(widget.topWidget != null ? 1 : 0), (widget.topWidget != null ? 1 : 0),
addRepaintBoundaries: true, addRepaintBoundaries: true,
@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
if (widget.visibleItemsListener != null) { if (widget.visibleItemsListener != null) {
_itemPositionsListener.itemPositions.removeListener(_positionListener); _itemPositionsListener.itemPositions.removeListener(_positionListener);
} }
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
super.dispose(); super.dispose();
} }
@ -308,6 +332,107 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
); );
} }
void _setDragStartIndex(AssetIndex index) {
setState(() {
_dragAnchorAssetIndex = index.rowIndex;
_dragAnchorSectionIndex = index.sectionIndex;
_dragging = true;
});
}
void _stopDrag() {
setState(() {
_dragging = false;
_draggedAssets.clear();
});
}
void _dragDragScroll(ScrollDirection direction) {
_scrollOffsetController.animateScroll(
offset: direction == ScrollDirection.forward ? 175 : -175,
duration: const Duration(milliseconds: 125),
);
}
void _handleDragAssetEnter(AssetIndex index) {
if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) {
return;
}
final dragAnchorSectionIndex = _dragAnchorSectionIndex!;
final dragAnchorAssetIndex = _dragAnchorAssetIndex!;
late final int startSectionIndex;
late final int startSectionAssetIndex;
late final int endSectionIndex;
late final int endSectionAssetIndex;
if (index.sectionIndex < dragAnchorSectionIndex) {
startSectionIndex = index.sectionIndex;
startSectionAssetIndex = index.rowIndex;
endSectionIndex = dragAnchorSectionIndex;
endSectionAssetIndex = dragAnchorAssetIndex;
} else if (index.sectionIndex > dragAnchorSectionIndex) {
startSectionIndex = dragAnchorSectionIndex;
startSectionAssetIndex = dragAnchorAssetIndex;
endSectionIndex = index.sectionIndex;
endSectionAssetIndex = index.rowIndex;
} else {
startSectionIndex = dragAnchorSectionIndex;
endSectionIndex = dragAnchorSectionIndex;
// If same section, assign proper start / end asset Index
if (dragAnchorAssetIndex < index.rowIndex) {
startSectionAssetIndex = dragAnchorAssetIndex;
endSectionAssetIndex = index.rowIndex;
} else {
startSectionAssetIndex = index.rowIndex;
endSectionAssetIndex = dragAnchorAssetIndex;
}
}
final selectedAssets = <Asset>{};
var currentSectionIndex = startSectionIndex;
while (currentSectionIndex < endSectionIndex) {
final section =
widget.renderList.elements.elementAtOrNull(currentSectionIndex);
if (section == null) continue;
final sectionAssets =
widget.renderList.loadAssets(section.offset, section.count);
if (currentSectionIndex == startSectionIndex) {
selectedAssets.addAll(
sectionAssets.slice(startSectionAssetIndex, sectionAssets.length),
);
} else {
selectedAssets.addAll(sectionAssets);
}
currentSectionIndex += 1;
}
final section = widget.renderList.elements.elementAtOrNull(endSectionIndex);
if (section != null) {
final sectionAssets =
widget.renderList.loadAssets(section.offset, section.count);
if (startSectionIndex == endSectionIndex) {
selectedAssets.addAll(
sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1),
);
} else {
selectedAssets.addAll(
sectionAssets.slice(0, endSectionAssetIndex + 1),
);
}
}
_deselectAssets(_draggedAssets.toList());
_draggedAssets.clear();
_draggedAssets.addAll(selectedAssets);
_selectAssets(_draggedAssets.toList());
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopScope( return PopScope(
@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
child: Stack( child: Stack(
children: [ children: [
_buildAssetGrid(), AssetDragRegion(
onStart: _setDragStartIndex,
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onScroll: _dragDragScroll,
onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback(
(_) => controlBottomAppBarNotifier.minimize(),
),
child: _buildAssetGrid(),
),
if (widget.showMultiSelectIndicator && widget.selectionActive) if (widget.showMultiSelectIndicator && widget.selectionActive)
_buildMultiSelectIndicator(), _buildMultiSelectIndicator(),
], ],
@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget {
/// A section for the render grid /// A section for the render grid
class _Section extends StatelessWidget { class _Section extends StatelessWidget {
final RenderAssetGridElement section; final RenderAssetGridElement section;
final int sectionIndex;
final Set<Asset> selectedAssets; final Set<Asset> selectedAssets;
final bool scrolling; final bool scrolling;
final double margin; final double margin;
@ -377,6 +512,7 @@ class _Section extends StatelessWidget {
const _Section({ const _Section({
required this.section, required this.section,
required this.sectionIndex,
required this.scrolling, required this.scrolling,
required this.margin, required this.margin,
required this.assetsPerRow, required this.assetsPerRow,
@ -435,6 +571,8 @@ class _Section extends StatelessWidget {
) )
: _AssetRow( : _AssetRow(
key: ValueKey(i), key: ValueKey(i),
rowStartIndex: i * assetsPerRow,
sectionIndex: sectionIndex,
assets: assetsToRender.nestedSlice( assets: assetsToRender.nestedSlice(
i * assetsPerRow, i * assetsPerRow,
min((i + 1) * assetsPerRow, section.count), min((i + 1) * assetsPerRow, section.count),
@ -522,6 +660,8 @@ class _Title extends StatelessWidget {
/// The row of assets /// The row of assets
class _AssetRow extends StatelessWidget { class _AssetRow extends StatelessWidget {
final List<Asset> assets; final List<Asset> assets;
final int rowStartIndex;
final int sectionIndex;
final Set<Asset> selectedAssets; final Set<Asset> selectedAssets;
final int absoluteOffset; final int absoluteOffset;
final double width; final double width;
@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget {
const _AssetRow({ const _AssetRow({
super.key, super.key,
required this.rowStartIndex,
required this.sectionIndex,
required this.assets, required this.assets,
required this.absoluteOffset, required this.absoluteOffset,
required this.width, required this.width,
@ -594,6 +736,9 @@ class _AssetRow extends StatelessWidget {
bottom: margin, bottom: margin,
right: last ? 0.0 : margin, right: last ? 0.0 : margin,
), ),
child: AssetIndexWrapper(
rowIndex: rowStartIndex + index,
sectionIndex: sectionIndex,
child: ThumbnailImage( child: ThumbnailImage(
asset: asset, asset: asset,
index: absoluteOffset + index, index: absoluteOffset + index,
@ -607,6 +752,7 @@ class _AssetRow extends StatelessWidget {
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack, showStack: showStack,
), ),
),
); );
}).toList(), }).toList(),
); );

View File

@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
@ -11,8 +12,17 @@ import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
class ControlBottomAppBar extends ConsumerWidget { final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
class ControlBottomAppBarNotifier with ChangeNotifier {
void minimize() {
notifyListeners();
}
}
class ControlBottomAppBar extends HookConsumerWidget {
final void Function(bool shareLocal) onShare; final void Function(bool shareLocal) onShare;
final void Function()? onFavorite; final void Function()? onFavorite;
final void Function()? onArchive; final void Function()? onArchive;
@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget {
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider); final sharedAlbums = ref.watch(sharedAlbumProvider);
const bottomPadding = 0.20; const bottomPadding = 0.20;
final scrollController = useDraggableScrollController();
void minimize() {
scrollController.animateTo(
bottomPadding,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
useEffect(
() {
controlBottomAppBarNotifier.addListener(minimize);
return () {
controlBottomAppBarNotifier.removeListener(minimize);
};
},
[],
);
void showForceDeleteDialog( void showForceDeleteDialog(
Function(bool) deleteCb, { Function(bool) deleteCb, {
@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget {
} }
return DraggableScrollableSheet( return DraggableScrollableSheet(
controller: scrollController,
initialChildSize: hasRemote ? 0.35 : bottomPadding, initialChildSize: hasRemote ? 0.35 : bottomPadding,
minChildSize: bottomPadding, minChildSize: bottomPadding,
maxChildSize: hasRemote ? 0.65 : bottomPadding, maxChildSize: hasRemote ? 0.65 : bottomPadding,

View File

@ -23,7 +23,7 @@ class CuratedPeopleRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const imageSize = 85.0; const imageSize = 80.0;
// Guard empty [content] // Guard empty [content]
if (content.isEmpty) { if (content.isEmpty) {

View File

@ -0,0 +1,13 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'immich_logo_provider.g.dart';
@riverpod
Future<Uint8List> immichLogo(ImmichLogoRef ref) async {
final json = await rootBundle.loadString('assets/immich-logo.json');
final j = jsonDecode(json);
return base64Decode(j['content']);
}

View File

@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'immich_logo_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$immichLogoHash() => r'040cc44fae3339e0f40a091fb3b2f2abe9f83acd';
/// See also [immichLogo].
@ProviderFor(immichLogo)
final immichLogoProvider = AutoDisposeFutureProvider<Uint8List>.internal(
immichLogo,
name: r'immichLogoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$immichLogoHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ImmichLogoRef = AutoDisposeFutureProviderRef<Uint8List>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/immich_logo_provider.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart'; import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@ -26,6 +27,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
final bool isEnableAutoBackup = final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup; backupState.backgroundBackup || backupState.autoBackup;
final ServerInfo serverInfoState = ref.watch(serverInfoProvider); final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
final immichLogo = ref.watch(immichLogoProvider);
final user = Store.tryGet(StoreKey.currentUser); final user = Store.tryGet(StoreKey.currentUser);
final isDarkTheme = context.isDarkTheme; final isDarkTheme = context.isDarkTheme;
const widgetSize = 30.0; const widgetSize = 30.0;
@ -152,14 +154,29 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
builder: (BuildContext context) { builder: (BuildContext context) {
return Row( return Row(
children: [ children: [
Container( Builder(
padding: const EdgeInsets.only(top: 3), builder: (context) {
height: 30, final today = DateTime.now();
if (today.month == 4 && today.day == 1) {
if (immichLogo.value == null) {
return const SizedBox.shrink();
}
return Image.memory(
immichLogo.value!,
fit: BoxFit.cover,
height: 80,
);
}
return Padding(
padding: const EdgeInsets.only(top: 3.0),
child: Image.asset( child: Image.asset(
height: 30,
context.isDarkTheme context.isDarkTheme
? 'assets/immich-logo-inline-dark.png' ? 'assets/immich-logo-inline-dark.png'
: 'assets/immich-logo-inline-light.png', : 'assets/immich-logo-inline-light.png',
), ),
);
},
), ),
], ],
); );

View File

@ -13,7 +13,7 @@ Name | Type | Description | Notes
**isVisible** | **bool** | | [optional] **isVisible** | **bool** | | [optional]
**isWatched** | **bool** | | [optional] **isWatched** | **bool** | | [optional]
**name** | **String** | | [optional] **name** | **String** | | [optional]
**ownerId** | **String** | | [optional] **ownerId** | **String** | |
**type** | [**LibraryType**](LibraryType.md) | | **type** | [**LibraryType**](LibraryType.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**importPath** | **String** | | **importPath** | **String** | |
**isValid** | **bool** | | [optional] [default to false] **isValid** | **bool** | | [default to false]
**message** | **String** | | [optional] **message** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -18,7 +18,7 @@ class CreateLibraryDto {
this.isVisible, this.isVisible,
this.isWatched, this.isWatched,
this.name, this.name,
this.ownerId, required this.ownerId,
required this.type, required this.type,
}); });
@ -50,13 +50,7 @@ class CreateLibraryDto {
/// ///
String? name; String? name;
/// String ownerId;
/// 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? ownerId;
LibraryType type; LibraryType type;
@ -78,7 +72,7 @@ class CreateLibraryDto {
(isVisible == null ? 0 : isVisible!.hashCode) + (isVisible == null ? 0 : isVisible!.hashCode) +
(isWatched == null ? 0 : isWatched!.hashCode) + (isWatched == null ? 0 : isWatched!.hashCode) +
(name == null ? 0 : name!.hashCode) + (name == null ? 0 : name!.hashCode) +
(ownerId == null ? 0 : ownerId!.hashCode) + (ownerId.hashCode) +
(type.hashCode); (type.hashCode);
@override @override
@ -103,11 +97,7 @@ class CreateLibraryDto {
} else { } else {
// json[r'name'] = null; // json[r'name'] = null;
} }
if (this.ownerId != null) {
json[r'ownerId'] = this.ownerId; json[r'ownerId'] = this.ownerId;
} else {
// json[r'ownerId'] = null;
}
json[r'type'] = this.type; json[r'type'] = this.type;
return json; return json;
} }
@ -129,7 +119,7 @@ class CreateLibraryDto {
isVisible: mapValueOfType<bool>(json, r'isVisible'), isVisible: mapValueOfType<bool>(json, r'isVisible'),
isWatched: mapValueOfType<bool>(json, r'isWatched'), isWatched: mapValueOfType<bool>(json, r'isWatched'),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId'), ownerId: mapValueOfType<String>(json, r'ownerId')!,
type: LibraryType.fromJson(json[r'type'])!, type: LibraryType.fromJson(json[r'type'])!,
); );
} }
@ -178,6 +168,7 @@ class CreateLibraryDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'ownerId',
'type', 'type',
}; };
} }

View File

@ -67,7 +67,7 @@ class ValidateLibraryImportPathResponseDto {
return ValidateLibraryImportPathResponseDto( return ValidateLibraryImportPathResponseDto(
importPath: mapValueOfType<String>(json, r'importPath')!, importPath: mapValueOfType<String>(json, r'importPath')!,
isValid: mapValueOfType<bool>(json, r'isValid') ?? false, isValid: mapValueOfType<bool>(json, r'isValid')!,
message: mapValueOfType<String>(json, r'message'), message: mapValueOfType<String>(json, r'message'),
); );
} }
@ -117,6 +117,7 @@ class ValidateLibraryImportPathResponseDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'importPath', 'importPath',
'isValid',
}; };
} }

View File

@ -7646,6 +7646,7 @@
} }
}, },
"required": [ "required": [
"ownerId",
"type" "type"
], ],
"type": "object" "type": "object"
@ -10689,7 +10690,8 @@
} }
}, },
"required": [ "required": [
"importPath" "importPath",
"isValid"
], ],
"type": "object" "type": "object"
}, },

View File

@ -466,7 +466,7 @@ export type CreateLibraryDto = {
isVisible?: boolean; isVisible?: boolean;
isWatched?: boolean; isWatched?: boolean;
name?: string; name?: string;
ownerId?: string; ownerId: string;
"type": LibraryType; "type": LibraryType;
}; };
export type UpdateLibraryDto = { export type UpdateLibraryDto = {
@ -491,7 +491,7 @@ export type ValidateLibraryDto = {
}; };
export type ValidateLibraryImportPathResponseDto = { export type ValidateLibraryImportPathResponseDto = {
importPath: string; importPath: string;
isValid?: boolean; isValid: boolean;
message?: string; message?: string;
}; };
export type ValidateLibraryResponseDto = { export type ValidateLibraryResponseDto = {

View File

@ -46,6 +46,7 @@ describe(`Library watcher (e2e)`, () => {
describe('Single import path', () => { describe('Single import path', () => {
beforeEach(async () => { beforeEach(async () => {
await api.libraryApi.create(server, admin.accessToken, { await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
}); });
@ -133,6 +134,7 @@ describe(`Library watcher (e2e)`, () => {
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true }); await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
await api.libraryApi.create(server, admin.accessToken, { await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [ importPaths: [
`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,
@ -190,6 +192,7 @@ describe(`Library watcher (e2e)`, () => {
beforeEach(async () => { beforeEach(async () => {
library = await api.libraryApi.create(server, admin.accessToken, { library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [ importPaths: [
`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`,

View File

@ -1,6 +1,6 @@
import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; import { LoginResponseDto } from '@app/domain';
import { LibraryController } from '@app/immich'; import { LibraryController } from '@app/immich';
import { AssetType, LibraryType } from '@app/infra/entities'; import { LibraryType } from '@app/infra/entities';
import { errorStub, uuidStub } from '@test/fixtures'; import { errorStub, uuidStub } from '@test/fixtures';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import request from 'supertest'; import request from 'supertest';
@ -41,6 +41,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
}); });
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
}); });
@ -72,6 +73,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
it('should scan new files', async () => { it('should scan new files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
}); });
@ -107,6 +109,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
describe('with refreshModifiedFiles=true', () => { describe('with refreshModifiedFiles=true', () => {
it('should reimport modified files', async () => { it('should reimport modified files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
}); });
@ -153,6 +156,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
it('should not reimport unmodified files', async () => { it('should not reimport unmodified files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
}); });
@ -192,6 +196,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
describe('with refreshAllFiles=true', () => { describe('with refreshAllFiles=true', () => {
it('should reimport all files', async () => { it('should reimport all files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
}); });
@ -251,6 +256,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
}); });
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
}); });
@ -277,6 +283,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
it('should not remove online files', async () => { it('should not remove online files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
}); });

View File

@ -153,7 +153,7 @@
"coverageDirectory": "./coverage", "coverageDirectory": "./coverage",
"coverageThreshold": { "coverageThreshold": {
"./src/domain/": { "./src/domain/": {
"branches": 79, "branches": 75,
"functions": 80, "functions": 80,
"lines": 90, "lines": 90,
"statements": 90 "statements": 90

View File

@ -33,12 +33,6 @@ export enum Permission {
TIMELINE_READ = 'timeline.read', TIMELINE_READ = 'timeline.read',
TIMELINE_DOWNLOAD = 'timeline.download', TIMELINE_DOWNLOAD = 'timeline.download',
LIBRARY_CREATE = 'library.create',
LIBRARY_READ = 'library.read',
LIBRARY_UPDATE = 'library.update',
LIBRARY_DELETE = 'library.delete',
LIBRARY_DOWNLOAD = 'library.download',
PERSON_READ = 'person.read', PERSON_READ = 'person.read',
PERSON_WRITE = 'person.write', PERSON_WRITE = 'person.write',
PERSON_MERGE = 'person.merge', PERSON_MERGE = 'person.merge',
@ -261,29 +255,6 @@ export class AccessCore {
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
} }
case Permission.LIBRARY_READ: {
if (auth.user.isAdmin) {
return new Set(ids);
}
const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids);
const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.LIBRARY_UPDATE: {
if (auth.user.isAdmin) {
return new Set(ids);
}
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
}
case Permission.LIBRARY_DELETE: {
if (auth.user.isAdmin) {
return new Set(ids);
}
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ: { case Permission.PERSON_READ: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids); return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
} }

View File

@ -15,6 +15,8 @@ import {
newStorageRepositoryMock, newStorageRepositoryMock,
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
newUserRepositoryMock, newUserRepositoryMock,
partnerStub,
userStub,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { JobName } from '../job'; import { JobName } from '../job';
@ -317,6 +319,7 @@ describe(AssetService.name, () => {
}); });
it('should set the title correctly', async () => { it('should set the title correctly', async () => {
partnerMock.getAll.mockResolvedValue([]);
assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]); assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
@ -324,7 +327,17 @@ describe(AssetService.name, () => {
{ title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] }, { title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] },
]); ]);
expect(assetMock.getByDayOfYear.mock.calls).toEqual([[authStub.admin.user.id, { day: 15, month: 1 }]]); expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
});
it('should get memories with partners with inTimeline enabled', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 });
expect(assetMock.getByDayOfYear.mock.calls).toEqual([
[[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }],
]);
}); });
}); });

View File

@ -172,7 +172,16 @@ export class AssetService {
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const assets = await this.assetRepository.getByDayOfYear(auth.user.id, dto);
// get partners id
const userIds: string[] = [auth.user.id];
const partners = await this.partnerRepository.getAll(auth.user.id);
const partnersIds = partners
.filter((partner) => partner.sharedBy && partner.inTimeline)
.map((partner) => partner.sharedById);
userIds.push(...partnersIds);
const assets = await this.assetRepository.getByDayOfYear(userIds, dto);
return _.chain(assets) return _.chain(assets)
.filter((asset) => asset.localDateTime.getFullYear() < currentYear) .filter((asset) => asset.localDateTime.getFullYear() < currentYear)

View File

@ -8,8 +8,8 @@ export class CreateLibraryDto {
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) @ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
type!: LibraryType; type!: LibraryType;
@ValidateUUID({ optional: true }) @ValidateUUID()
ownerId?: string; ownerId!: string;
@IsString() @IsString()
@Optional() @Optional()

View File

@ -706,7 +706,7 @@ describe(LibraryService.name, () => {
libraryMock.getUploadLibraryCount.mockResolvedValue(2); libraryMock.getUploadLibraryCount.mockResolvedValue(2);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); await sut.delete(libraryStub.externalLibrary1.id);
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.LIBRARY_DELETE, name: JobName.LIBRARY_DELETE,
@ -721,9 +721,7 @@ describe(LibraryService.name, () => {
libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.getUploadLibraryCount.mockResolvedValue(1);
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.delete(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf( await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
BadRequestException,
);
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
@ -735,7 +733,7 @@ describe(LibraryService.name, () => {
libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.getUploadLibraryCount.mockResolvedValue(1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); await sut.delete(libraryStub.externalLibrary1.id);
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.LIBRARY_DELETE, name: JobName.LIBRARY_DELETE,
@ -757,26 +755,16 @@ describe(LibraryService.name, () => {
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
await sut.init(); await sut.init();
await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id);
expect(mockClose).toHaveBeenCalled(); expect(mockClose).toHaveBeenCalled();
}); });
}); });
describe('getCount', () => {
it('should call the repository', async () => {
libraryMock.getCountForUser.mockResolvedValue(17);
await expect(sut.getCount(authStub.admin)).resolves.toBe(17);
expect(libraryMock.getCountForUser).toHaveBeenCalledWith(authStub.admin.user.id);
});
});
describe('get', () => { describe('get', () => {
it('should return a library', async () => { it('should return a library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual( await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.uploadLibrary1.id, id: libraryStub.uploadLibrary1.id,
name: libraryStub.uploadLibrary1.name, name: libraryStub.uploadLibrary1.name,
@ -789,15 +777,16 @@ describe(LibraryService.name, () => {
it('should throw an error when a library is not found', async () => { it('should throw an error when a library is not found', async () => {
libraryMock.get.mockResolvedValue(null); libraryMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id);
}); });
}); });
describe('getStatistics', () => { describe('getStatistics', () => {
it('should return library statistics', async () => { it('should return library statistics', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({ await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({
photos: 10, photos: 10,
videos: 0, videos: 0,
total: 10, total: 10,
@ -812,11 +801,7 @@ describe(LibraryService.name, () => {
describe('external library', () => { describe('external library', () => {
it('should create with default settings', async () => { it('should create with default settings', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect( await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual(
sut.create(authStub.admin, {
type: LibraryType.EXTERNAL,
}),
).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
@ -845,10 +830,7 @@ describe(LibraryService.name, () => {
it('should create with name', async () => { it('should create with name', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect( await expect(
sut.create(authStub.admin, { sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }),
type: LibraryType.EXTERNAL,
name: 'My Awesome Library',
}),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
@ -878,10 +860,7 @@ describe(LibraryService.name, () => {
it('should create invisible', async () => { it('should create invisible', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect( await expect(
sut.create(authStub.admin, { sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, isVisible: false }),
type: LibraryType.EXTERNAL,
isVisible: false,
}),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
@ -911,7 +890,8 @@ describe(LibraryService.name, () => {
it('should create with import paths', async () => { it('should create with import paths', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect( await expect(
sut.create(authStub.admin, { sut.create({
ownerId: authStub.admin.user.id,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: ['/data/images', '/data/videos'], importPaths: ['/data/images', '/data/videos'],
}), }),
@ -948,7 +928,8 @@ describe(LibraryService.name, () => {
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.init(); await sut.init();
await sut.create(authStub.admin, { await sut.create({
ownerId: authStub.admin.user.id,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
}); });
@ -963,7 +944,8 @@ describe(LibraryService.name, () => {
it('should create with exclusion patterns', async () => { it('should create with exclusion patterns', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect( await expect(
sut.create(authStub.admin, { sut.create({
ownerId: authStub.admin.user.id,
type: LibraryType.EXTERNAL, type: LibraryType.EXTERNAL,
exclusionPatterns: ['*.tmp', '*.bak'], exclusionPatterns: ['*.tmp', '*.bak'],
}), }),
@ -997,11 +979,7 @@ describe(LibraryService.name, () => {
describe('upload library', () => { describe('upload library', () => {
it('should create with default settings', async () => { it('should create with default settings', async () => {
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
await expect( await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual(
sut.create(authStub.admin, {
type: LibraryType.UPLOAD,
}),
).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.uploadLibrary1.id, id: libraryStub.uploadLibrary1.id,
type: LibraryType.UPLOAD, type: LibraryType.UPLOAD,
@ -1030,10 +1008,7 @@ describe(LibraryService.name, () => {
it('should create with name', async () => { it('should create with name', async () => {
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
await expect( await expect(
sut.create(authStub.admin, { sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }),
type: LibraryType.UPLOAD,
name: 'My Awesome Library',
}),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.uploadLibrary1.id, id: libraryStub.uploadLibrary1.id,
@ -1062,7 +1037,8 @@ describe(LibraryService.name, () => {
it('should not create with import paths', async () => { it('should not create with import paths', async () => {
await expect( await expect(
sut.create(authStub.admin, { sut.create({
ownerId: authStub.admin.user.id,
type: LibraryType.UPLOAD, type: LibraryType.UPLOAD,
importPaths: ['/data/images', '/data/videos'], importPaths: ['/data/images', '/data/videos'],
}), }),
@ -1073,7 +1049,8 @@ describe(LibraryService.name, () => {
it('should not create with exclusion patterns', async () => { it('should not create with exclusion patterns', async () => {
await expect( await expect(
sut.create(authStub.admin, { sut.create({
ownerId: authStub.admin.user.id,
type: LibraryType.UPLOAD, type: LibraryType.UPLOAD,
exclusionPatterns: ['*.tmp', '*.bak'], exclusionPatterns: ['*.tmp', '*.bak'],
}), }),
@ -1084,10 +1061,7 @@ describe(LibraryService.name, () => {
it('should not create watched', async () => { it('should not create watched', async () => {
await expect( await expect(
sut.create(authStub.admin, { sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }),
type: LibraryType.UPLOAD,
isWatched: true,
}),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(storageMock.watch).not.toHaveBeenCalled(); expect(storageMock.watch).not.toHaveBeenCalled();
@ -1117,14 +1091,9 @@ describe(LibraryService.name, () => {
it('should update library', async () => { it('should update library', async () => {
libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toEqual( libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
mapLibrary(libraryStub.uploadLibrary1), await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1));
); expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
expect(libraryMock.update).toHaveBeenCalledWith(
expect.objectContaining({
id: authStub.admin.user.id,
}),
);
}); });
it('should re-watch library when updating import paths', async () => { it('should re-watch library when updating import paths', async () => {
@ -1137,15 +1106,11 @@ describe(LibraryService.name, () => {
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
await expect( await expect(sut.update('library-id', { importPaths: ['/data/user1/foo'] })).resolves.toEqual(
sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }), mapLibrary(libraryStub.externalLibraryWithImportPaths1),
).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1));
expect(libraryMock.update).toHaveBeenCalledWith(
expect.objectContaining({
id: authStub.admin.user.id,
}),
); );
expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
expect(storageMock.watch).toHaveBeenCalledWith( expect(storageMock.watch).toHaveBeenCalledWith(
libraryStub.externalLibraryWithImportPaths1.importPaths, libraryStub.externalLibraryWithImportPaths1.importPaths,
expect.anything(), expect.anything(),
@ -1158,15 +1123,11 @@ describe(LibraryService.name, () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual( await expect(sut.update('library-id', { exclusionPatterns: ['bar'] })).resolves.toEqual(
mapLibrary(libraryStub.externalLibraryWithImportPaths1), mapLibrary(libraryStub.externalLibraryWithImportPaths1),
); );
expect(libraryMock.update).toHaveBeenCalledWith( expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
expect.objectContaining({
id: authStub.admin.user.id,
}),
);
expect(storageMock.watch).toHaveBeenCalledWith( expect(storageMock.watch).toHaveBeenCalledWith(
expect.arrayContaining([expect.any(String)]), expect.arrayContaining([expect.any(String)]),
expect.anything(), expect.anything(),
@ -1411,7 +1372,7 @@ describe(LibraryService.name, () => {
it('should queue a library scan of external library', async () => { it('should queue a library scan of external library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {}); await sut.queueScan(libraryStub.externalLibrary1.id, {});
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
@ -1430,9 +1391,7 @@ describe(LibraryService.name, () => {
it('should not queue a library scan of upload library', async () => { it('should not queue a library scan of upload library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf( await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException);
BadRequestException,
);
expect(jobMock.queue).not.toBeCalled(); expect(jobMock.queue).not.toBeCalled();
}); });
@ -1440,7 +1399,7 @@ describe(LibraryService.name, () => {
it('should queue a library scan of all modified assets', async () => { it('should queue a library scan of all modified assets', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
@ -1459,7 +1418,7 @@ describe(LibraryService.name, () => {
it('should queue a forced library scan', async () => { it('should queue a forced library scan', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true }); await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true });
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
@ -1478,7 +1437,7 @@ describe(LibraryService.name, () => {
describe('queueEmptyTrash', () => { describe('queueEmptyTrash', () => {
it('should queue the trash job', async () => { it('should queue the trash job', async () => {
await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id); await sut.queueRemoveOffline(libraryStub.externalLibrary1.id);
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
@ -1566,17 +1525,15 @@ describe(LibraryService.name, () => {
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
importPaths: ['/data/user1/'], importPaths: [
});
expect(result.importPaths).toEqual([
{ {
importPath: '/data/user1/', importPath: '/data/user1/',
isValid: true, isValid: true,
message: undefined, message: undefined,
}, },
]); ],
});
}); });
it('should detect when path does not exist', async () => { it('should detect when path does not exist', async () => {
@ -1585,17 +1542,15 @@ describe(LibraryService.name, () => {
throw error; throw error;
}); });
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
importPaths: ['/data/user1/'], importPaths: [
});
expect(result.importPaths).toEqual([
{ {
importPath: '/data/user1/', importPath: '/data/user1/',
isValid: false, isValid: false,
message: 'Path does not exist (ENOENT)', message: 'Path does not exist (ENOENT)',
}, },
]); ],
});
}); });
it('should detect when path is not a directory', async () => { it('should detect when path is not a directory', async () => {
@ -1603,17 +1558,15 @@ describe(LibraryService.name, () => {
isDirectory: () => false, isDirectory: () => false,
} as Stats); } as Stats);
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { await expect(sut.validate('library-id', { importPaths: ['/data/user1/file'] })).resolves.toEqual({
importPaths: ['/data/user1/file'], importPaths: [
});
expect(result.importPaths).toEqual([
{ {
importPath: '/data/user1/file', importPath: '/data/user1/file',
isValid: false, isValid: false,
message: 'Not a directory', message: 'Not a directory',
}, },
]); ],
});
}); });
it('should return an unknown exception from stat', async () => { it('should return an unknown exception from stat', async () => {
@ -1621,17 +1574,15 @@ describe(LibraryService.name, () => {
throw new Error('Unknown error'); throw new Error('Unknown error');
}); });
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
importPaths: ['/data/user1/'], importPaths: [
});
expect(result.importPaths).toEqual([
{ {
importPath: '/data/user1/', importPath: '/data/user1/',
isValid: false, isValid: false,
message: 'Error: Unknown error', message: 'Error: Unknown error',
}, },
]); ],
});
}); });
it('should detect when access rights are missing', async () => { it('should detect when access rights are missing', async () => {
@ -1641,17 +1592,15 @@ describe(LibraryService.name, () => {
storageMock.checkFileExists.mockResolvedValue(false); storageMock.checkFileExists.mockResolvedValue(false);
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
importPaths: ['/data/user1/'], importPaths: [
});
expect(result.importPaths).toEqual([
{ {
importPath: '/data/user1/', importPath: '/data/user1/',
isValid: false, isValid: false,
message: 'Lacking read permission for folder', message: 'Lacking read permission for folder',
}, },
]); ],
});
}); });
it('should detect when import path is in immich media folder', async () => { it('should detect when import path is in immich media folder', async () => {
@ -1659,11 +1608,10 @@ describe(LibraryService.name, () => {
const validImport = libraryStub.hasImmichPaths.importPaths[1]; const validImport = libraryStub.hasImmichPaths.importPaths[1];
when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true); when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true);
const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, { await expect(
importPaths: libraryStub.hasImmichPaths.importPaths, sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }),
}); ).resolves.toEqual({
importPaths: [
expect(result.importPaths).toEqual([
{ {
importPath: libraryStub.hasImmichPaths.importPaths[0], importPath: libraryStub.hasImmichPaths.importPaths[0],
isValid: false, isValid: false,
@ -1678,7 +1626,8 @@ describe(LibraryService.name, () => {
isValid: false, isValid: false,
message: 'Cannot use media upload folder for external libraries', message: 'Cannot use media upload folder for external libraries',
}, },
]); ],
});
}); });
}); });
}); });

View File

@ -8,8 +8,7 @@ import { EventEmitter } from 'node:events';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import path, { basename, parse } from 'node:path'; import path, { basename, parse } from 'node:path';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
import { AccessCore, Permission } from '../access'; import { AccessCore } from '../access';
import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util'; import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util';
import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
@ -226,24 +225,17 @@ export class LibraryService extends EventEmitter {
} }
} }
async getStatistics(auth: AuthDto, id: string): Promise<LibraryStatsResponseDto> { async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); await this.findOrFail(id);
return this.repository.getStatistics(id); return this.repository.getStatistics(id);
} }
async getCount(auth: AuthDto): Promise<number> { async get(id: string): Promise<LibraryResponseDto> {
return this.repository.getCountForUser(auth.user.id);
}
async get(auth: AuthDto, id: string): Promise<LibraryResponseDto> {
await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
const library = await this.findOrFail(id); const library = await this.findOrFail(id);
return mapLibrary(library); return mapLibrary(library);
} }
async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise<LibraryResponseDto[]> { async getAll(dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
const libraries = await this.repository.getAll(false, dto.type); const libraries = await this.repository.getAll(false, dto.type);
return libraries.map((library) => mapLibrary(library)); return libraries.map((library) => mapLibrary(library));
} }
@ -257,7 +249,7 @@ export class LibraryService extends EventEmitter {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> { async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> {
switch (dto.type) { switch (dto.type) {
case LibraryType.EXTERNAL: { case LibraryType.EXTERNAL: {
if (!dto.name) { if (!dto.name) {
@ -282,14 +274,8 @@ export class LibraryService extends EventEmitter {
} }
} }
let ownerId = auth.user.id;
if (dto.ownerId) {
ownerId = dto.ownerId;
}
const library = await this.repository.create({ const library = await this.repository.create({
ownerId, ownerId: dto.ownerId,
name: dto.name, name: dto.name,
type: dto.type, type: dto.type,
importPaths: dto.importPaths ?? [], importPaths: dto.importPaths ?? [],
@ -297,7 +283,7 @@ export class LibraryService extends EventEmitter {
isVisible: dto.isVisible ?? true, isVisible: dto.isVisible ?? true,
}); });
this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`); this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`);
if (dto.type === LibraryType.EXTERNAL) { if (dto.type === LibraryType.EXTERNAL) {
await this.watch(library.id); await this.watch(library.id);
@ -364,29 +350,19 @@ export class LibraryService extends EventEmitter {
return validation; return validation;
} }
public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> { async validate(id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); const importPaths = await Promise.all(
(dto.importPaths || []).map((importPath) => this.validateImportPath(importPath)),
const response = new ValidateLibraryResponseDto();
if (dto.importPaths) {
response.importPaths = await Promise.all(
dto.importPaths.map(async (importPath) => {
return await this.validateImportPath(importPath);
}),
); );
return { importPaths };
} }
return response; async update(id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
} await this.findOrFail(id);
async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
const library = await this.repository.update({ id, ...dto }); const library = await this.repository.update({ id, ...dto });
if (dto.importPaths) { if (dto.importPaths) {
const validation = await this.validate(auth, id, { importPaths: dto.importPaths }); const validation = await this.validate(id, { importPaths: dto.importPaths });
if (validation.importPaths) { if (validation.importPaths) {
for (const path of validation.importPaths) { for (const path of validation.importPaths) {
if (!path.isValid) { if (!path.isValid) {
@ -404,11 +380,9 @@ export class LibraryService extends EventEmitter {
return mapLibrary(library); return mapLibrary(library);
} }
async delete(auth: AuthDto, id: string) { async delete(id: string) {
await this.access.requirePermission(auth, Permission.LIBRARY_DELETE, id);
const library = await this.findOrFail(id); const library = await this.findOrFail(id);
const uploadCount = await this.repository.getUploadLibraryCount(auth.user.id); const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId);
if (library.type === LibraryType.UPLOAD && uploadCount <= 1) { if (library.type === LibraryType.UPLOAD && uploadCount <= 1) {
throw new BadRequestException('Cannot delete the last upload library'); throw new BadRequestException('Cannot delete the last upload library');
} }
@ -565,11 +539,9 @@ export class LibraryService extends EventEmitter {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) { async queueScan(id: string, dto: ScanLibraryDto) {
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); const library = await this.findOrFail(id);
if (library.type !== LibraryType.EXTERNAL) {
const library = await this.repository.get(id);
if (!library || library.type !== LibraryType.EXTERNAL) {
throw new BadRequestException('Can only refresh external libraries'); throw new BadRequestException('Can only refresh external libraries');
} }
@ -583,16 +555,9 @@ export class LibraryService extends EventEmitter {
}); });
} }
async queueRemoveOffline(auth: AuthDto, id: string) { async queueRemoveOffline(id: string) {
this.logger.verbose(`Removing offline files from library: ${id}`); this.logger.verbose(`Removing offline files from library: ${id}`);
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } });
await this.jobRepository.queue({
name: JobName.LIBRARY_REMOVE_OFFLINE,
data: {
id,
},
});
} }
async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> { async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> {

View File

@ -26,7 +26,6 @@ export interface IAccessRepository {
library: { library: {
checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>; checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>;
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
}; };
timeline: { timeline: {

View File

@ -123,7 +123,7 @@ export interface IAssetRepository {
select?: FindOptionsSelect<AssetEntity>, select?: FindOptionsSelect<AssetEntity>,
): Promise<AssetEntity[]>; ): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>; getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>; getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>; getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;

View File

@ -1,5 +1,4 @@
import { import {
AuthDto,
CreateLibraryDto as CreateDto, CreateLibraryDto as CreateDto,
LibraryService, LibraryService,
LibraryStatsResponseDto, LibraryStatsResponseDto,
@ -12,7 +11,7 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AdminRoute, Auth, Authenticated } from '../app.guard'; import { AdminRoute, Authenticated } from '../app.guard';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Library') @ApiTags('Library')
@ -23,55 +22,52 @@ export class LibraryController {
constructor(private service: LibraryService) {} constructor(private service: LibraryService) {}
@Get() @Get()
getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise<ResponseDto[]> { getAllLibraries(@Query() dto: SearchLibraryDto): Promise<ResponseDto[]> {
return this.service.getAll(auth, dto); return this.service.getAll(dto);
} }
@Post() @Post()
createLibrary(@Auth() auth: AuthDto, @Body() dto: CreateDto): Promise<ResponseDto> { createLibrary(@Body() dto: CreateDto): Promise<ResponseDto> {
return this.service.create(auth, dto); return this.service.create(dto);
} }
@Put(':id') @Put(':id')
updateLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> { updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> {
return this.service.update(auth, id, dto); return this.service.update(id, dto);
} }
@Get(':id') @Get(':id')
getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> { getLibrary(@Param() { id }: UUIDParamDto): Promise<ResponseDto> {
return this.service.get(auth, id); return this.service.get(id);
} }
@Post(':id/validate') @Post(':id/validate')
@HttpCode(200) @HttpCode(200)
validate( // TODO: change endpoint to validate current settings instead
@Auth() auth: AuthDto, validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
@Param() { id }: UUIDParamDto, return this.service.validate(id, dto);
@Body() dto: ValidateLibraryDto,
): Promise<ValidateLibraryResponseDto> {
return this.service.validate(auth, id, dto);
} }
@Delete(':id') @Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id); return this.service.delete(id);
} }
@Get(':id/statistics') @Get(':id/statistics')
getLibraryStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> { getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
return this.service.getStatistics(auth, id); return this.service.getStatistics(id);
} }
@Post(':id/scan') @Post(':id/scan')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
scanLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) {
return this.service.queueScan(auth, id, dto); return this.service.queueScan(id, dto);
} }
@Post(':id/removeOffline') @Post(':id/removeOffline')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
removeOfflineFiles(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { removeOfflineFiles(@Param() { id }: UUIDParamDto) {
return this.service.queueRemoveOffline(auth, id); return this.service.queueRemoveOffline(id);
} }
} }

View File

@ -307,10 +307,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
} }
class LibraryAccess implements ILibraryAccess { class LibraryAccess implements ILibraryAccess {
constructor( constructor(private libraryRepository: Repository<LibraryEntity>) {}
private libraryRepository: Repository<LibraryEntity>,
private partnerRepository: Repository<PartnerEntity>,
) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
@ -329,22 +326,6 @@ class LibraryAccess implements ILibraryAccess {
}) })
.then((libraries) => new Set(libraries.map((library) => library.id))); .then((libraries) => new Set(libraries.map((library) => library.id)));
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> {
if (partnerIds.size === 0) {
return new Set();
}
return this.partnerRepository
.createQueryBuilder('partner')
.select('partner.sharedById')
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
.andWhere('partner.sharedWithId = :userId', { userId })
.getMany()
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
}
} }
class TimelineAccess implements ITimelineAccess { class TimelineAccess implements ITimelineAccess {
@ -457,7 +438,7 @@ export class AccessRepository implements IAccessRepository {
this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
this.authDevice = new AuthDeviceAccess(tokenRepository); this.authDevice = new AuthDeviceAccess(tokenRepository);
this.library = new LibraryAccess(libraryRepository, partnerRepository); this.library = new LibraryAccess(libraryRepository);
this.person = new PersonAccess(assetFaceRepository, personRepository); this.person = new PersonAccess(assetFaceRepository, personRepository);
this.partner = new PartnerAccess(partnerRepository); this.partner = new PartnerAccess(partnerRepository);
this.timeline = new TimelineAccess(partnerRepository); this.timeline = new TimelineAccess(partnerRepository);

View File

@ -109,18 +109,18 @@ export class AssetRepository implements IAssetRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
getByDayOfYear(ownerId: string, { day, month }: MonthDay): Promise<AssetEntity[]> { getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<AssetEntity[]> {
return this.repository return this.repository
.createQueryBuilder('entity') .createQueryBuilder('entity')
.where( .where(
`entity.ownerId = :ownerId `entity.ownerId IN (:...ownerIds)
AND entity.isVisible = true AND entity.isVisible = true
AND entity.isArchived = false AND entity.isArchived = false
AND entity.resizePath IS NOT NULL AND entity.resizePath IS NOT NULL
AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day
AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`,
{ {
ownerId, ownerIds,
day, day,
month, month,
}, },

View File

@ -196,16 +196,6 @@ WHERE
) )
AND ("LibraryEntity"."deletedAt" IS NULL) AND ("LibraryEntity"."deletedAt" IS NULL)
-- AccessRepository.library.checkPartnerAccess
SELECT
"partner"."sharedById" AS "partner_sharedById",
"partner"."sharedWithId" AS "partner_sharedWithId"
FROM
"partners" "partner"
WHERE
"partner"."sharedById" IN ($1)
AND "partner"."sharedWithId" = $2
-- AccessRepository.person.checkOwnerAccess -- AccessRepository.person.checkOwnerAccess
SELECT SELECT
"PersonEntity"."id" AS "PersonEntity_id" "PersonEntity"."id" AS "PersonEntity_id"

View File

@ -76,89 +76,6 @@ WHERE
ORDER BY ORDER BY
"AssetEntity"."fileCreatedAt" DESC "AssetEntity"."fileCreatedAt" DESC
-- AssetRepository.getByDayOfYear
SELECT
"entity"."id" AS "entity_id",
"entity"."deviceAssetId" AS "entity_deviceAssetId",
"entity"."ownerId" AS "entity_ownerId",
"entity"."libraryId" AS "entity_libraryId",
"entity"."deviceId" AS "entity_deviceId",
"entity"."type" AS "entity_type",
"entity"."originalPath" AS "entity_originalPath",
"entity"."resizePath" AS "entity_resizePath",
"entity"."webpPath" AS "entity_webpPath",
"entity"."thumbhash" AS "entity_thumbhash",
"entity"."encodedVideoPath" AS "entity_encodedVideoPath",
"entity"."createdAt" AS "entity_createdAt",
"entity"."updatedAt" AS "entity_updatedAt",
"entity"."deletedAt" AS "entity_deletedAt",
"entity"."fileCreatedAt" AS "entity_fileCreatedAt",
"entity"."localDateTime" AS "entity_localDateTime",
"entity"."fileModifiedAt" AS "entity_fileModifiedAt",
"entity"."isFavorite" AS "entity_isFavorite",
"entity"."isArchived" AS "entity_isArchived",
"entity"."isExternal" AS "entity_isExternal",
"entity"."isReadOnly" AS "entity_isReadOnly",
"entity"."isOffline" AS "entity_isOffline",
"entity"."checksum" AS "entity_checksum",
"entity"."duration" AS "entity_duration",
"entity"."isVisible" AS "entity_isVisible",
"entity"."livePhotoVideoId" AS "entity_livePhotoVideoId",
"entity"."originalFileName" AS "entity_originalFileName",
"entity"."sidecarPath" AS "entity_sidecarPath",
"entity"."stackId" AS "entity_stackId",
"exifInfo"."assetId" AS "exifInfo_assetId",
"exifInfo"."description" AS "exifInfo_description",
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
"exifInfo"."orientation" AS "exifInfo_orientation",
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
"exifInfo"."timeZone" AS "exifInfo_timeZone",
"exifInfo"."latitude" AS "exifInfo_latitude",
"exifInfo"."longitude" AS "exifInfo_longitude",
"exifInfo"."projectionType" AS "exifInfo_projectionType",
"exifInfo"."city" AS "exifInfo_city",
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
"exifInfo"."state" AS "exifInfo_state",
"exifInfo"."country" AS "exifInfo_country",
"exifInfo"."make" AS "exifInfo_make",
"exifInfo"."model" AS "exifInfo_model",
"exifInfo"."lensModel" AS "exifInfo_lensModel",
"exifInfo"."fNumber" AS "exifInfo_fNumber",
"exifInfo"."focalLength" AS "exifInfo_focalLength",
"exifInfo"."iso" AS "exifInfo_iso",
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."fps" AS "exifInfo_fps"
FROM
"assets" "entity"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id"
WHERE
(
"entity"."ownerId" = $1
AND "entity"."isVisible" = true
AND "entity"."isArchived" = false
AND "entity"."resizePath" IS NOT NULL
AND EXTRACT(
DAY
FROM
"entity"."localDateTime" AT TIME ZONE 'UTC'
) = $2
AND EXTRACT(
MONTH
FROM
"entity"."localDateTime" AT TIME ZONE 'UTC'
) = $3
)
AND ("entity"."deletedAt" IS NULL)
ORDER BY
"entity"."localDateTime" DESC
-- AssetRepository.getByIds -- AssetRepository.getByIds
SELECT SELECT
"AssetEntity"."id" AS "AssetEntity_id", "AssetEntity"."id" AS "AssetEntity_id",

View File

@ -42,7 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
library: { library: {
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
checkPartnerAccess: jest.fn().mockResolvedValue(new Set()),
}, },
timeline: { timeline: {

File diff suppressed because one or more lines are too long

View File

@ -2,11 +2,11 @@
import { serveFile, type AssetResponseDto } from '@immich/sdk'; import { serveFile, type AssetResponseDto } from '@immich/sdk';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { getKey } from '$lib/utils';
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
const loadAssetData = async () => { const loadAssetData = async () => {
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false }); const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
return URL.createObjectURL(data); return URL.createObjectURL(data);
}; };
</script> </script>

View File

@ -14,7 +14,7 @@
}; };
</script> </script>
<div class="absolute top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}"> <div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
<Button <Button
size={'sm'} size={'sm'}
rounded={false} rounded={false}

View File

@ -20,7 +20,7 @@
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
cancel: void; cancel: void;
submit: { ownerId: string | null }; submit: { ownerId: string };
delete: void; delete: void;
}>(); }>();

View File

@ -83,8 +83,13 @@
/> />
</div> </div>
<div class="flex flex-col w-full mt-2"> <div class="flex flex-col w-full mt-2">
<label for="timezone">Timezone</label> <Combobox
<Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." /> bind:selectedOption
id="settings-timezone"
label="Timezone"
options={timezones}
placeholder="Search timezone..."
/>
</div> </div>
</div> </div>
</ConfirmDialogue> </ConfirmDialogue>

View File

@ -11,48 +11,93 @@
<script lang="ts"> <script lang="ts">
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher, tick } from 'svelte';
import IconButton from '../elements/buttons/icon-button.svelte'; import IconButton from '../elements/buttons/icon-button.svelte';
import type { FormEventHandler } from 'svelte/elements';
import { shortcuts } from '$lib/utils/shortcut';
import { clickOutside } from '$lib/utils/click-outside';
export let id: string | undefined = undefined; /**
* Unique identifier for the combobox.
*/
export let id: string;
export let label: string;
export let hideLabel = false;
export let options: ComboBoxOption[] = []; export let options: ComboBoxOption[] = [];
export let selectedOption: ComboBoxOption | undefined; export let selectedOption: ComboBoxOption | undefined;
export let placeholder = ''; export let placeholder = '';
/**
* Indicates whether or not the dropdown autocomplete list should be visible.
*/
let isOpen = false; let isOpen = false;
let inputFocused = false; /**
* Keeps track of whether the combobox is actively being used.
*/
let isActive = false;
let searchQuery = selectedOption?.label || ''; let searchQuery = selectedOption?.label || '';
let selectedIndex: number | undefined;
let optionRefs: HTMLElement[] = [];
const inputId = `combobox-${id}`;
const listboxId = `listbox-${id}`;
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
$: {
searchQuery = selectedOption ? selectedOption.label : '';
}
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
select: ComboBoxOption | undefined; select: ComboBoxOption | undefined;
click: void; click: void;
}>(); }>();
const handleClick = () => { const activate = () => {
searchQuery = ''; isActive = true;
openDropdown();
};
const deactivate = () => {
searchQuery = selectedOption ? selectedOption.label : '';
isActive = false;
closeDropdown();
};
const openDropdown = () => {
isOpen = true; isOpen = true;
inputFocused = true;
dispatch('click');
}; };
let handleOutClick = () => { const closeDropdown = () => {
// In rare cases it's possible for the input to still have focus and
// outclick to fire.
if (!inputFocused) {
isOpen = false; isOpen = false;
selectedIndex = undefined;
};
const incrementSelectedIndex = async (increment: number) => {
if (filteredOptions.length === 0) {
selectedIndex = 0;
} else if (selectedIndex === undefined) {
selectedIndex = increment === 1 ? 0 : filteredOptions.length - 1;
} else {
selectedIndex = (selectedIndex + increment + filteredOptions.length) % filteredOptions.length;
} }
await tick();
optionRefs[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}; };
let handleSelect = (option: ComboBoxOption) => { const onInput: FormEventHandler<HTMLInputElement> = (event) => {
openDropdown();
searchQuery = event.currentTarget.value;
selectedIndex = undefined;
optionRefs[0]?.scrollIntoView({ block: 'nearest' });
};
let onSelect = (option: ComboBoxOption) => {
selectedOption = option; selectedOption = option;
searchQuery = option.label;
dispatch('select', option); dispatch('select', option);
isOpen = false; closeDropdown();
}; };
const onClear = () => { const onClear = () => {
@ -62,30 +107,80 @@
}; };
</script> </script>
<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" use:clickOutside on:outclick={handleOutClick}> <label class="text-sm text-black dark:text-white" class:sr-only={hideLabel} for={inputId}>{label}</label>
<div
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
use:clickOutside={{ onOutclick: deactivate }}
on:focusout={(e) => {
if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) {
deactivate();
}
}}
>
<div> <div>
{#if isOpen} {#if isActive}
<div class="absolute inset-y-0 left-0 flex items-center pl-3"> <div class="absolute inset-y-0 left-0 flex items-center pl-3">
<div class="dark:text-immich-dark-fg/75"> <div class="dark:text-immich-dark-fg/75">
<Icon path={mdiMagnify} /> <Icon path={mdiMagnify} ariaHidden={true} />
</div> </div>
</div> </div>
{/if} {/if}
<input <input
{id}
{placeholder} {placeholder}
role="combobox" aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''}
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls={id} autocomplete="off"
class="immich-form-input text-sm text-left w-full !pr-12 transition-all" class:!pl-8={isActive}
class:!pl-8={isOpen}
class:!rounded-b-none={isOpen} class:!rounded-b-none={isOpen}
class:cursor-pointer={!isOpen} class:cursor-pointer={!isActive}
value={isOpen ? '' : selectedOption?.label || ''} class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
on:input={(e) => (searchQuery = e.currentTarget.value)} id={inputId}
on:focus={handleClick} on:click={activate}
on:blur={() => (inputFocused = false)} on:focus={activate}
on:input={onInput}
role="combobox"
type="text"
value={searchQuery}
use:shortcuts={[
{
shortcut: { key: 'ArrowUp' },
onShortcut: () => {
openDropdown();
void incrementSelectedIndex(-1);
},
},
{
shortcut: { key: 'ArrowDown' },
onShortcut: () => {
openDropdown();
void incrementSelectedIndex(1);
},
},
{
shortcut: { key: 'ArrowDown', alt: true },
onShortcut: () => {
openDropdown();
},
},
{
shortcut: { key: 'Enter' },
onShortcut: () => {
if (selectedIndex !== undefined && filteredOptions.length > 0) {
onSelect(filteredOptions[selectedIndex]);
}
closeDropdown();
},
},
{
shortcut: { key: 'Escape' },
onShortcut: () => {
closeDropdown();
},
},
]}
/> />
<div <div
@ -95,37 +190,51 @@
> >
{#if selectedOption} {#if selectedOption}
<IconButton color="transparent-gray" on:click={onClear} title="Clear value"> <IconButton color="transparent-gray" on:click={onClear} title="Clear value">
<Icon path={mdiClose} /> <Icon path={mdiClose} ariaLabel="Clear value" />
</IconButton> </IconButton>
{:else if !isOpen} {:else if !isOpen}
<Icon path={mdiUnfoldMoreHorizontal} /> <Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
{/if} {/if}
</div> </div>
</div> </div>
{#if isOpen} <ul
<div
role="listbox" role="listbox"
id={listboxId}
transition:fly={{ duration: 250 }} transition:fly={{ duration: 250 }}
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10" class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10"
class:border={isOpen}
tabindex="-1"
> >
{#if isOpen}
{#if filteredOptions.length === 0} {#if filteredOptions.length === 0}
<div class="px-4 py-2 font-medium">No results</div> <!-- svelte-ignore a11y-click-events-have-key-events -->
{/if} <li
{#each filteredOptions as option (option.label)} role="option"
{@const selected = option.label === selectedOption?.label} aria-selected={selectedIndex === 0}
<button aria-disabled={true}
type="button" class:bg-gray-100={selectedIndex === 0}
class:dark:bg-gray-700={selectedIndex === 0}
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default"
id={`${listboxId}-${0}`}
on:click={() => closeDropdown()}
>
No results
</li>
{/if}
{#each filteredOptions as option, index (option.label)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
aria-selected={index === selectedIndex}
bind:this={optionRefs[index]}
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
id={`${listboxId}-${index}`}
on:click={() => onSelect(option)}
role="option" role="option"
aria-selected={selected}
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
class:bg-gray-300={selected}
class:dark:bg-gray-600={selected}
on:click={() => handleSelect(option)}
> >
{option.label} {option.label}
</button> </li>
{/each} {/each}
</div>
{/if} {/if}
</ul>
</div> </div>

View File

@ -2,8 +2,10 @@
import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg'; import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg';
import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg'; import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg';
import logoNoText from '$lib/assets/immich-logo.svg'; import logoNoText from '$lib/assets/immich-logo.svg';
import { content as alternativeLogo } from '$lib/assets/immich-logo.json';
import { Theme } from '$lib/constants'; import { Theme } from '$lib/constants';
import { colorTheme } from '$lib/stores/preferences.store'; import { colorTheme } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
import type { HTMLImgAttributes } from 'svelte/elements'; import type { HTMLImgAttributes } from 'svelte/elements';
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -14,11 +16,17 @@
export let noText = false; export let noText = false;
export let draggable = false; export let draggable = false;
const today = DateTime.now().toLocal();
</script> </script>
{#if today.month === 4 && today.day === 1}
<img src="data:image/png;base64, {alternativeLogo}" alt="Immich Logo" class="h-20" {draggable} />
{:else}
<img <img
src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl} src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
alt="Immich Logo" alt="Immich Logo"
{draggable} {draggable}
{...$$restProps} {...$$restProps}
/> />
{/if}

View File

@ -11,6 +11,7 @@
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { shortcut } from '$lib/utils/shortcut';
export let value = ''; export let value = '';
export let grayTheme: boolean; export let grayTheme: boolean;
@ -84,7 +85,16 @@
}; };
</script> </script>
<div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}> <svelte:window
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
onFocusOut();
},
}}
/>
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}>
<form <form
draggable="false" draggable="false"
autocomplete="off" autocomplete="off"
@ -118,6 +128,12 @@
bind:this={input} bind:this={input}
on:click={onFocusIn} on:click={onFocusIn}
disabled={showFilter} disabled={showFilter}
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
onFocusOut();
},
}}
/> />
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all"> <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">

View File

@ -40,24 +40,24 @@
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1"> <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
<div class="w-full"> <div class="w-full">
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
<Combobox <Combobox
id="search-camera-make" id="camera-make"
options={toComboBoxOptions(makes)} label="Make"
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
on:select={({ detail }) => (filters.make = detail?.value)} on:select={({ detail }) => (filters.make = detail?.value)}
options={toComboBoxOptions(makes)}
placeholder="Search camera make..." placeholder="Search camera make..."
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
/> />
</div> </div>
<div class="w-full"> <div class="w-full">
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
<Combobox <Combobox
id="search-camera-model" id="camera-model"
options={toComboBoxOptions(models)} label="Model"
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
on:select={({ detail }) => (filters.model = detail?.value)} on:select={({ detail }) => (filters.model = detail?.value)}
options={toComboBoxOptions(models)}
placeholder="Search camera model..." placeholder="Search camera model..."
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
/> />
</div> </div>
</div> </div>

View File

@ -62,35 +62,35 @@
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1"> <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
<div class="w-full"> <div class="w-full">
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
<Combobox <Combobox
id="search-place-country" id="location-country"
options={toComboBoxOptions(countries)} label="Country"
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
on:select={({ detail }) => (filters.country = detail?.value)} on:select={({ detail }) => (filters.country = detail?.value)}
options={toComboBoxOptions(countries)}
placeholder="Search country..." placeholder="Search country..."
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
/> />
</div> </div>
<div class="w-full"> <div class="w-full">
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
<Combobox <Combobox
id="search-place-state" id="location-state"
options={toComboBoxOptions(states)} label="State"
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
on:select={({ detail }) => (filters.state = detail?.value)} on:select={({ detail }) => (filters.state = detail?.value)}
options={toComboBoxOptions(states)}
placeholder="Search state..." placeholder="Search state..."
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
/> />
</div> </div>
<div class="w-full"> <div class="w-full">
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
<Combobox <Combobox
id="search-place-city" id="location-city"
options={toComboBoxOptions(cities)} label="City"
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
on:select={({ detail }) => (filters.city = detail?.value)} on:select={({ detail }) => (filters.city = detail?.value)}
options={toComboBoxOptions(cities)}
placeholder="Search city..." placeholder="Search city..."
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
/> />
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
export let id: string;
export let title: string; export let title: string;
export let comboboxPlaceholder: string; export let comboboxPlaceholder: string;
export let subtitle = ''; export let subtitle = '';
@ -32,6 +33,9 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<Combobox <Combobox
{id}
label={title}
hideLabel={true}
{selectedOption} {selectedOption}
{options} {options}
placeholder={comboboxPlaceholder} placeholder={comboboxPlaceholder}

View File

@ -86,6 +86,7 @@
{#if $locale !== undefined} {#if $locale !== undefined}
<div class="ml-4"> <div class="ml-4">
<SettingCombobox <SettingCombobox
id="custom-locale"
comboboxPlaceholder="Searching locales..." comboboxPlaceholder="Searching locales..."
{selectedOption} {selectedOption}
options={getAllLanguages()} options={getAllLanguages()}

View File

@ -28,7 +28,6 @@
removeOfflineFiles, removeOfflineFiles,
scanLibrary, scanLibrary,
updateLibrary, updateLibrary,
type CreateLibraryDto,
type LibraryResponseDto, type LibraryResponseDto,
type LibraryStatsResponseDto, type LibraryStatsResponseDto,
type UserResponseDto, type UserResponseDto,
@ -117,14 +116,9 @@
} }
} }
const handleCreate = async (ownerId: string | null) => { const handleCreate = async (ownerId: string) => {
try { try {
let createLibraryDto: CreateLibraryDto = { type: LibraryType.External }; const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } });
if (ownerId) {
createLibraryDto = { ...createLibraryDto, ownerId };
}
const createdLibrary = await createLibrary({ createLibraryDto });
notificationController.show({ notificationController.show({
message: `Created library: ${createdLibrary.name}`, message: `Created library: ${createdLibrary.name}`,

View File

@ -286,7 +286,7 @@
<tr class="flex w-full place-items-center p-2 md:p-5"> <tr class="flex w-full place-items-center p-2 md:p-5">
<th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2"> <th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
<div class="px-3"> <div class="px-3">
<p>UNTRACKS FILES {extras.length > 0 ? `(${extras.length})` : ''}</p> <p>UNTRACKED FILES {extras.length > 0 ? `(${extras.length})` : ''}</p>
<p class="text-gray-600 dark:text-gray-300 mt-1"> <p class="text-gray-600 dark:text-gray-300 mt-1">
These files are not tracked by the application. They can be the results of failed moves, These files are not tracked by the application. They can be the results of failed moves,
interrupted uploads, or left behind due to a bug interrupted uploads, or left behind due to a bug