Compare commits

..

3 Commits

Author SHA1 Message Date
bwees faaeaace1d fix: memory viewer bar 2026-05-22 16:50:18 -05:00
bwees 2837de2029 chore: ci fix 2026-05-22 14:01:11 -05:00
bwees eb27635f22 refactor: use ControlBar UI Library component 2026-05-22 11:35:16 -05:00
96 changed files with 432 additions and 2642 deletions
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
force: false, force: false,
ids: [assetToTrash.id], ids: [assetToTrash.id],
}); });
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByText('Trash', { exact: true }).click(); await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id); await thumbnailUtils.expectInViewport(page, assetToTrash.id);
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
ids: [assetToArchive.id], ids: [assetToArchive.id],
}); });
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id); await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByRole('link').getByText('Archive').click(); await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id); await thumbnailUtils.expectInViewport(page, assetToArchive.id);
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
}); });
// ensure thumbnail still exists and has favorite icon // ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id); await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByRole('link').getByText('Favorites').click(); await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt); await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
+1 -6
View File
@@ -92,12 +92,10 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**getAlbumMapMarkers**](doc//AlbumsApi.md#getalbummapmarkers) | **GET** /albums/{id}/map-markers | Retrieve album map markers *AlbumsApi* | [**getAlbumMapMarkers**](doc//AlbumsApi.md#getalbummapmarkers) | **GET** /albums/{id}/map-markers | Retrieve album map markers
*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics *AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums
*AlbumsApi* | [**getOwnAlbumUser**](doc//AlbumsApi.md#getownalbumuser) | **GET** /albums/{id}/user/self | Get own sharing permissions
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role
*AlbumsApi* | [**updateOwnAlbumUser**](doc//AlbumsApi.md#updateownalbumuser) | **PUT** /albums/{id}/user/self | Update own sharing permissions
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload
*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset *AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset
*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key *AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key
@@ -454,7 +452,7 @@ Class | Method | HTTP request | Description
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md) - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
- [MemoryType](doc//MemoryType.md) - [MemoryType](doc//MemoryType.md)
- [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergeFaceClusterDto](doc//MergeFaceClusterDto.md) - [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md)
- [MirrorAxis](doc//MirrorAxis.md) - [MirrorAxis](doc//MirrorAxis.md)
- [MirrorParameters](doc//MirrorParameters.md) - [MirrorParameters](doc//MirrorParameters.md)
@@ -546,8 +544,6 @@ Class | Method | HTTP request | Description
- [SharedLinkType](doc//SharedLinkType.md) - [SharedLinkType](doc//SharedLinkType.md)
- [SharedLinksResponse](doc//SharedLinksResponse.md) - [SharedLinksResponse](doc//SharedLinksResponse.md)
- [SharedLinksUpdate](doc//SharedLinksUpdate.md) - [SharedLinksUpdate](doc//SharedLinksUpdate.md)
- [SharingOptionsResponseDto](doc//SharingOptionsResponseDto.md)
- [SharingPermission](doc//SharingPermission.md)
- [SignUpDto](doc//SignUpDto.md) - [SignUpDto](doc//SignUpDto.md)
- [SmartSearchDto](doc//SmartSearchDto.md) - [SmartSearchDto](doc//SmartSearchDto.md)
- [SourceType](doc//SourceType.md) - [SourceType](doc//SourceType.md)
@@ -647,7 +643,6 @@ Class | Method | HTTP request | Description
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UpdateSharingOptionsDto](doc//UpdateSharingOptionsDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md)
- [UserAdminCreateDto](doc//UserAdminCreateDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md)
- [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
+1 -4
View File
@@ -198,7 +198,7 @@ part 'model/memory_search_order.dart';
part 'model/memory_statistics_response_dto.dart'; part 'model/memory_statistics_response_dto.dart';
part 'model/memory_type.dart'; part 'model/memory_type.dart';
part 'model/memory_update_dto.dart'; part 'model/memory_update_dto.dart';
part 'model/merge_face_cluster_dto.dart'; part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart'; part 'model/metadata_search_dto.dart';
part 'model/mirror_axis.dart'; part 'model/mirror_axis.dart';
part 'model/mirror_parameters.dart'; part 'model/mirror_parameters.dart';
@@ -290,8 +290,6 @@ part 'model/shared_link_response_dto.dart';
part 'model/shared_link_type.dart'; part 'model/shared_link_type.dart';
part 'model/shared_links_response.dart'; part 'model/shared_links_response.dart';
part 'model/shared_links_update.dart'; part 'model/shared_links_update.dart';
part 'model/sharing_options_response_dto.dart';
part 'model/sharing_permission.dart';
part 'model/sign_up_dto.dart'; part 'model/sign_up_dto.dart';
part 'model/smart_search_dto.dart'; part 'model/smart_search_dto.dart';
part 'model/source_type.dart'; part 'model/source_type.dart';
@@ -391,7 +389,6 @@ part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart'; part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart'; part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart'; part 'model/update_library_dto.dart';
part 'model/update_sharing_options_dto.dart';
part 'model/usage_by_user_dto.dart'; part 'model/usage_by_user_dto.dart';
part 'model/user_admin_create_dto.dart'; part 'model/user_admin_create_dto.dart';
part 'model/user_admin_delete_dto.dart'; part 'model/user_admin_delete_dto.dart';
-110
View File
@@ -580,63 +580,6 @@ class AlbumsApi {
return null; return null;
} }
/// Get own sharing permissions
///
/// Get the own sharing permissions in a specific album.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getOwnAlbumUserWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/self'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Get own sharing permissions
///
/// Get the own sharing permissions in a specific album.
///
/// Parameters:
///
/// * [String] id (required):
Future<SharingOptionsResponseDto?> getOwnAlbumUser(String id,) async {
final response = await getOwnAlbumUserWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SharingOptionsResponseDto',) as SharingOptionsResponseDto;
}
return null;
}
/// Remove assets from an album /// Remove assets from an album
/// ///
/// Remove multiple assets from a specific album by its ID. /// Remove multiple assets from a specific album by its ID.
@@ -873,57 +816,4 @@ class AlbumsApi {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
} }
/// Update own sharing permissions
///
/// Change the own sharing permissions in a specific album.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required):
Future<Response> updateOwnAlbumUserWithHttpInfo(String id, UpdateSharingOptionsDto updateSharingOptionsDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/self'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = updateSharingOptionsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Update own sharing permissions
///
/// Change the own sharing permissions in a specific album.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required):
Future<void> updateOwnAlbumUser(String id, UpdateSharingOptionsDto updateSharingOptionsDto,) async {
final response = await updateOwnAlbumUserWithHttpInfo(id, updateSharingOptionsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
} }
+6 -6
View File
@@ -448,14 +448,14 @@ class PeopleApi {
/// ///
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [MergeFaceClusterDto] mergeFaceClusterDto (required): /// * [MergePersonDto] mergePersonDto (required):
Future<Response> mergePersonWithHttpInfo(String id, MergeFaceClusterDto mergeFaceClusterDto,) async { Future<Response> mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/people/{id}/merge' final apiPath = r'/people/{id}/merge'
.replaceAll('{id}', id); .replaceAll('{id}', id);
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = mergeFaceClusterDto; Object? postBody = mergePersonDto;
final queryParams = <QueryParam>[]; final queryParams = <QueryParam>[];
final headerParams = <String, String>{}; final headerParams = <String, String>{};
@@ -483,9 +483,9 @@ class PeopleApi {
/// ///
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [MergeFaceClusterDto] mergeFaceClusterDto (required): /// * [MergePersonDto] mergePersonDto (required):
Future<List<BulkIdResponseDto>?> mergePerson(String id, MergeFaceClusterDto mergeFaceClusterDto,) async { Future<List<BulkIdResponseDto>?> mergePerson(String id, MergePersonDto mergePersonDto,) async {
final response = await mergePersonWithHttpInfo(id, mergeFaceClusterDto,); final response = await mergePersonWithHttpInfo(id, mergePersonDto,);
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+2 -8
View File
@@ -442,8 +442,8 @@ class ApiClient {
return MemoryTypeTypeTransformer().decode(value); return MemoryTypeTypeTransformer().decode(value);
case 'MemoryUpdateDto': case 'MemoryUpdateDto':
return MemoryUpdateDto.fromJson(value); return MemoryUpdateDto.fromJson(value);
case 'MergeFaceClusterDto': case 'MergePersonDto':
return MergeFaceClusterDto.fromJson(value); return MergePersonDto.fromJson(value);
case 'MetadataSearchDto': case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value); return MetadataSearchDto.fromJson(value);
case 'MirrorAxis': case 'MirrorAxis':
@@ -626,10 +626,6 @@ class ApiClient {
return SharedLinksResponse.fromJson(value); return SharedLinksResponse.fromJson(value);
case 'SharedLinksUpdate': case 'SharedLinksUpdate':
return SharedLinksUpdate.fromJson(value); return SharedLinksUpdate.fromJson(value);
case 'SharingOptionsResponseDto':
return SharingOptionsResponseDto.fromJson(value);
case 'SharingPermission':
return SharingPermissionTypeTransformer().decode(value);
case 'SignUpDto': case 'SignUpDto':
return SignUpDto.fromJson(value); return SignUpDto.fromJson(value);
case 'SmartSearchDto': case 'SmartSearchDto':
@@ -828,8 +824,6 @@ class ApiClient {
return UpdateAssetDto.fromJson(value); return UpdateAssetDto.fromJson(value);
case 'UpdateLibraryDto': case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value); return UpdateLibraryDto.fromJson(value);
case 'UpdateSharingOptionsDto':
return UpdateSharingOptionsDto.fromJson(value);
case 'UsageByUserDto': case 'UsageByUserDto':
return UsageByUserDto.fromJson(value); return UsageByUserDto.fromJson(value);
case 'UserAdminCreateDto': case 'UserAdminCreateDto':
-3
View File
@@ -163,9 +163,6 @@ String parameterToString(dynamic value) {
if (value is SharedLinkType) { if (value is SharedLinkType) {
return SharedLinkTypeTypeTransformer().encode(value).toString(); return SharedLinkTypeTypeTransformer().encode(value).toString();
} }
if (value is SharingPermission) {
return SharingPermissionTypeTransformer().encode(value).toString();
}
if (value is SourceType) { if (value is SourceType) {
return SourceTypeTypeTransformer().encode(value).toString(); return SourceTypeTypeTransformer().encode(value).toString();
} }
+1 -9
View File
@@ -37,7 +37,6 @@ class AssetResponseDto {
this.owner, this.owner,
required this.ownerId, required this.ownerId,
this.people = const [], this.people = const [],
this.permissions = const [],
this.resized, this.resized,
this.stack, this.stack,
this.tags = const [], this.tags = const [],
@@ -141,8 +140,6 @@ class AssetResponseDto {
List<PersonResponseDto> people; List<PersonResponseDto> people;
List<SharingPermission> permissions;
/// Is resized /// Is resized
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
@@ -198,7 +195,6 @@ class AssetResponseDto {
other.owner == owner && other.owner == owner &&
other.ownerId == ownerId && other.ownerId == ownerId &&
_deepEquality.equals(other.people, people) && _deepEquality.equals(other.people, people) &&
_deepEquality.equals(other.permissions, permissions) &&
other.resized == resized && other.resized == resized &&
other.stack == stack && other.stack == stack &&
_deepEquality.equals(other.tags, tags) && _deepEquality.equals(other.tags, tags) &&
@@ -235,7 +231,6 @@ class AssetResponseDto {
(owner == null ? 0 : owner!.hashCode) + (owner == null ? 0 : owner!.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(people.hashCode) + (people.hashCode) +
(permissions.hashCode) +
(resized == null ? 0 : resized!.hashCode) + (resized == null ? 0 : resized!.hashCode) +
(stack == null ? 0 : stack!.hashCode) + (stack == null ? 0 : stack!.hashCode) +
(tags.hashCode) + (tags.hashCode) +
@@ -246,7 +241,7 @@ class AssetResponseDto {
(width == null ? 0 : width!.hashCode); (width == null ? 0 : width!.hashCode);
@override @override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, permissions=$permissions, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -306,7 +301,6 @@ class AssetResponseDto {
} }
json[r'ownerId'] = this.ownerId; json[r'ownerId'] = this.ownerId;
json[r'people'] = this.people; json[r'people'] = this.people;
json[r'permissions'] = this.permissions;
if (this.resized != null) { if (this.resized != null) {
json[r'resized'] = this.resized; json[r'resized'] = this.resized;
} else { } else {
@@ -367,7 +361,6 @@ class AssetResponseDto {
owner: UserResponseDto.fromJson(json[r'owner']), owner: UserResponseDto.fromJson(json[r'owner']),
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
people: PersonResponseDto.listFromJson(json[r'people']), people: PersonResponseDto.listFromJson(json[r'people']),
permissions: SharingPermission.listFromJson(json[r'permissions']),
resized: mapValueOfType<bool>(json, r'resized'), resized: mapValueOfType<bool>(json, r'resized'),
stack: AssetStackResponseDto.fromJson(json[r'stack']), stack: AssetStackResponseDto.fromJson(json[r'stack']),
tags: TagResponseDto.listFromJson(json[r'tags']), tags: TagResponseDto.listFromJson(json[r'tags']),
@@ -440,7 +433,6 @@ class AssetResponseDto {
'originalFileName', 'originalFileName',
'originalPath', 'originalPath',
'ownerId', 'ownerId',
'permissions',
'thumbhash', 'thumbhash',
'type', 'type',
'updatedAt', 'updatedAt',
-3
View File
@@ -42,7 +42,6 @@ class JobName {
static const databaseBackup = JobName._(r'DatabaseBackup'); static const databaseBackup = JobName._(r'DatabaseBackup');
static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll'); static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll');
static const facialRecognition = JobName._(r'FacialRecognition'); static const facialRecognition = JobName._(r'FacialRecognition');
static const facialRecognitionMerge = JobName._(r'FacialRecognitionMerge');
static const fileDelete = JobName._(r'FileDelete'); static const fileDelete = JobName._(r'FileDelete');
static const fileMigrationQueueAll = JobName._(r'FileMigrationQueueAll'); static const fileMigrationQueueAll = JobName._(r'FileMigrationQueueAll');
static const libraryDeleteCheck = JobName._(r'LibraryDeleteCheck'); static const libraryDeleteCheck = JobName._(r'LibraryDeleteCheck');
@@ -101,7 +100,6 @@ class JobName {
databaseBackup, databaseBackup,
facialRecognitionQueueAll, facialRecognitionQueueAll,
facialRecognition, facialRecognition,
facialRecognitionMerge,
fileDelete, fileDelete,
fileMigrationQueueAll, fileMigrationQueueAll,
libraryDeleteCheck, libraryDeleteCheck,
@@ -195,7 +193,6 @@ class JobNameTypeTransformer {
case r'DatabaseBackup': return JobName.databaseBackup; case r'DatabaseBackup': return JobName.databaseBackup;
case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll; case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll;
case r'FacialRecognition': return JobName.facialRecognition; case r'FacialRecognition': return JobName.facialRecognition;
case r'FacialRecognitionMerge': return JobName.facialRecognitionMerge;
case r'FileDelete': return JobName.fileDelete; case r'FileDelete': return JobName.fileDelete;
case r'FileMigrationQueueAll': return JobName.fileMigrationQueueAll; case r'FileMigrationQueueAll': return JobName.fileMigrationQueueAll;
case r'LibraryDeleteCheck': return JobName.libraryDeleteCheck; case r'LibraryDeleteCheck': return JobName.libraryDeleteCheck;
-3
View File
@@ -29,7 +29,6 @@ class ManualJobName {
static const memoryCleanup = ManualJobName._(r'memory-cleanup'); static const memoryCleanup = ManualJobName._(r'memory-cleanup');
static const memoryCreate = ManualJobName._(r'memory-create'); static const memoryCreate = ManualJobName._(r'memory-create');
static const backupDatabase = ManualJobName._(r'backup-database'); static const backupDatabase = ManualJobName._(r'backup-database');
static const personGroupMerge = ManualJobName._(r'person-group-merge');
/// List of all possible values in this [enum][ManualJobName]. /// List of all possible values in this [enum][ManualJobName].
static const values = <ManualJobName>[ static const values = <ManualJobName>[
@@ -39,7 +38,6 @@ class ManualJobName {
memoryCleanup, memoryCleanup,
memoryCreate, memoryCreate,
backupDatabase, backupDatabase,
personGroupMerge,
]; ];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
@@ -84,7 +82,6 @@ class ManualJobNameTypeTransformer {
case r'memory-cleanup': return ManualJobName.memoryCleanup; case r'memory-cleanup': return ManualJobName.memoryCleanup;
case r'memory-create': return ManualJobName.memoryCreate; case r'memory-create': return ManualJobName.memoryCreate;
case r'backup-database': return ManualJobName.backupDatabase; case r'backup-database': return ManualJobName.backupDatabase;
case r'person-group-merge': return ManualJobName.personGroupMerge;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
@@ -10,17 +10,17 @@
part of openapi.api; part of openapi.api;
class MergeFaceClusterDto { class MergePersonDto {
/// Returns a new [MergeFaceClusterDto] instance. /// Returns a new [MergePersonDto] instance.
MergeFaceClusterDto({ MergePersonDto({
this.ids = const [], this.ids = const [],
}); });
/// Face cluster IDs to merge /// Person IDs to merge
List<String> ids; List<String> ids;
@override @override
bool operator ==(Object other) => identical(this, other) || other is MergeFaceClusterDto && bool operator ==(Object other) => identical(this, other) || other is MergePersonDto &&
_deepEquality.equals(other.ids, ids); _deepEquality.equals(other.ids, ids);
@override @override
@@ -29,7 +29,7 @@ class MergeFaceClusterDto {
(ids.hashCode); (ids.hashCode);
@override @override
String toString() => 'MergeFaceClusterDto[ids=$ids]'; String toString() => 'MergePersonDto[ids=$ids]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -37,15 +37,15 @@ class MergeFaceClusterDto {
return json; return json;
} }
/// Returns a new [MergeFaceClusterDto] instance and imports its values from /// Returns a new [MergePersonDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static MergeFaceClusterDto? fromJson(dynamic value) { static MergePersonDto? fromJson(dynamic value) {
upgradeDto(value, "MergeFaceClusterDto"); upgradeDto(value, "MergePersonDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return MergeFaceClusterDto( return MergePersonDto(
ids: json[r'ids'] is Iterable ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false) ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
@@ -54,11 +54,11 @@ class MergeFaceClusterDto {
return null; return null;
} }
static List<MergeFaceClusterDto> listFromJson(dynamic json, {bool growable = false,}) { static List<MergePersonDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MergeFaceClusterDto>[]; final result = <MergePersonDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = MergeFaceClusterDto.fromJson(row); final value = MergePersonDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -67,12 +67,12 @@ class MergeFaceClusterDto {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, MergeFaceClusterDto> mapFromJson(dynamic json) { static Map<String, MergePersonDto> mapFromJson(dynamic json) {
final map = <String, MergeFaceClusterDto>{}; final map = <String, MergePersonDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = MergeFaceClusterDto.fromJson(entry.value); final value = MergePersonDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -81,14 +81,14 @@ class MergeFaceClusterDto {
return map; return map;
} }
// maps a json object with a list of MergeFaceClusterDto-objects as value to a dart map // maps a json object with a list of MergePersonDto-objects as value to a dart map
static Map<String, List<MergeFaceClusterDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<MergePersonDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MergeFaceClusterDto>>{}; final map = <String, List<MergePersonDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = MergeFaceClusterDto.listFromJson(entry.value, growable: growable,); map[entry.key] = MergePersonDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;
+1 -14
View File
@@ -15,7 +15,6 @@ class PersonResponseDto {
PersonResponseDto({ PersonResponseDto({
required this.birthDate, required this.birthDate,
this.color, this.color,
required this.faceClusterId,
required this.id, required this.id,
this.isFavorite, this.isFavorite,
required this.isHidden, required this.isHidden,
@@ -36,9 +35,6 @@ class PersonResponseDto {
/// ///
String? color; String? color;
/// Face cluster ID
String? faceClusterId;
/// Person ID /// Person ID
String id; String id;
@@ -73,7 +69,6 @@ class PersonResponseDto {
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.birthDate == birthDate && other.birthDate == birthDate &&
other.color == color && other.color == color &&
other.faceClusterId == faceClusterId &&
other.id == id && other.id == id &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.isHidden == isHidden && other.isHidden == isHidden &&
@@ -86,7 +81,6 @@ class PersonResponseDto {
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) + (birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) + (color == null ? 0 : color!.hashCode) +
(faceClusterId == null ? 0 : faceClusterId!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden.hashCode) + (isHidden.hashCode) +
@@ -95,7 +89,7 @@ class PersonResponseDto {
(updatedAt == null ? 0 : updatedAt!.hashCode); (updatedAt == null ? 0 : updatedAt!.hashCode);
@override @override
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, faceClusterId=$faceClusterId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -108,11 +102,6 @@ class PersonResponseDto {
json[r'color'] = this.color; json[r'color'] = this.color;
} else { } else {
// json[r'color'] = null; // json[r'color'] = null;
}
if (this.faceClusterId != null) {
json[r'faceClusterId'] = this.faceClusterId;
} else {
// json[r'faceClusterId'] = null;
} }
json[r'id'] = this.id; json[r'id'] = this.id;
if (this.isFavorite != null) { if (this.isFavorite != null) {
@@ -142,7 +131,6 @@ class PersonResponseDto {
return PersonResponseDto( return PersonResponseDto(
birthDate: mapDateTime(json, r'birthDate', r''), birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'), color: mapValueOfType<String>(json, r'color'),
faceClusterId: mapValueOfType<String>(json, r'faceClusterId'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!, isHidden: mapValueOfType<bool>(json, r'isHidden')!,
@@ -197,7 +185,6 @@ class PersonResponseDto {
/// 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>{
'birthDate', 'birthDate',
'faceClusterId',
'id', 'id',
'isHidden', 'isHidden',
'name', 'name',
-107
View File
@@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SharingOptionsResponseDto {
/// Returns a new [SharingOptionsResponseDto] instance.
SharingOptionsResponseDto({
required this.inTimeline,
this.permissions = const [],
});
bool inTimeline;
List<SharingPermission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is SharingOptionsResponseDto &&
other.inTimeline == inTimeline &&
_deepEquality.equals(other.permissions, permissions);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(inTimeline.hashCode) +
(permissions.hashCode);
@override
String toString() => 'SharingOptionsResponseDto[inTimeline=$inTimeline, permissions=$permissions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'inTimeline'] = this.inTimeline;
json[r'permissions'] = this.permissions;
return json;
}
/// Returns a new [SharingOptionsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SharingOptionsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "SharingOptionsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SharingOptionsResponseDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<SharingOptionsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingOptionsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SharingOptionsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SharingOptionsResponseDto> mapFromJson(dynamic json) {
final map = <String, SharingOptionsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SharingOptionsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SharingOptionsResponseDto-objects as value to a dart map
static Map<String, List<SharingOptionsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SharingOptionsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SharingOptionsResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'inTimeline',
'permissions',
};
}
-112
View File
@@ -1,112 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Sharing permission schema
class SharingPermission {
/// Instantiate a new enum with the provided [value].
const SharingPermission._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const all = SharingPermission._(r'all');
static const assetPeriodRead = SharingPermission._(r'asset.read');
static const assetPeriodUpdate = SharingPermission._(r'asset.update');
static const assetPeriodEdit = SharingPermission._(r'asset.edit');
static const assetPeriodDelete = SharingPermission._(r'asset.delete');
static const assetPeriodShare = SharingPermission._(r'asset.share');
static const exifPeriodRead = SharingPermission._(r'exif.read');
static const personPeriodRead = SharingPermission._(r'person.read');
static const personPeriodUpdate = SharingPermission._(r'person.update');
static const personPeriodMerge = SharingPermission._(r'person.merge');
static const personPeriodDelete = SharingPermission._(r'person.delete');
/// List of all possible values in this [enum][SharingPermission].
static const values = <SharingPermission>[
all,
assetPeriodRead,
assetPeriodUpdate,
assetPeriodEdit,
assetPeriodDelete,
assetPeriodShare,
exifPeriodRead,
personPeriodRead,
personPeriodUpdate,
personPeriodMerge,
personPeriodDelete,
];
static SharingPermission? fromJson(dynamic value) => SharingPermissionTypeTransformer().decode(value);
static List<SharingPermission> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingPermission>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SharingPermission.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [SharingPermission] to String,
/// and [decode] dynamic data back to [SharingPermission].
class SharingPermissionTypeTransformer {
factory SharingPermissionTypeTransformer() => _instance ??= const SharingPermissionTypeTransformer._();
const SharingPermissionTypeTransformer._();
String encode(SharingPermission data) => data.value;
/// Decodes a [dynamic value][data] to a SharingPermission.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
SharingPermission? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'all': return SharingPermission.all;
case r'asset.read': return SharingPermission.assetPeriodRead;
case r'asset.update': return SharingPermission.assetPeriodUpdate;
case r'asset.edit': return SharingPermission.assetPeriodEdit;
case r'asset.delete': return SharingPermission.assetPeriodDelete;
case r'asset.share': return SharingPermission.assetPeriodShare;
case r'exif.read': return SharingPermission.exifPeriodRead;
case r'person.read': return SharingPermission.personPeriodRead;
case r'person.update': return SharingPermission.personPeriodUpdate;
case r'person.merge': return SharingPermission.personPeriodMerge;
case r'person.delete': return SharingPermission.personPeriodDelete;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [SharingPermissionTypeTransformer] instance.
static SharingPermissionTypeTransformer? _instance;
}
+14 -14
View File
@@ -19,11 +19,11 @@ class SyncAssetFaceV2 {
required this.boundingBoxY1, required this.boundingBoxY1,
required this.boundingBoxY2, required this.boundingBoxY2,
required this.deletedAt, required this.deletedAt,
required this.faceClusterId,
required this.id, required this.id,
required this.imageHeight, required this.imageHeight,
required this.imageWidth, required this.imageWidth,
required this.isVisible, required this.isVisible,
required this.personId,
required this.sourceType, required this.sourceType,
}); });
@@ -57,9 +57,6 @@ class SyncAssetFaceV2 {
/// Face deleted at /// Face deleted at
DateTime? deletedAt; DateTime? deletedAt;
/// Person ID
String? faceClusterId;
/// Asset face ID /// Asset face ID
String id; String id;
@@ -78,6 +75,9 @@ class SyncAssetFaceV2 {
/// Is the face visible in the asset /// Is the face visible in the asset
bool isVisible; bool isVisible;
/// Person ID
String? personId;
/// Source type /// Source type
String sourceType; String sourceType;
@@ -89,11 +89,11 @@ class SyncAssetFaceV2 {
other.boundingBoxY1 == boundingBoxY1 && other.boundingBoxY1 == boundingBoxY1 &&
other.boundingBoxY2 == boundingBoxY2 && other.boundingBoxY2 == boundingBoxY2 &&
other.deletedAt == deletedAt && other.deletedAt == deletedAt &&
other.faceClusterId == faceClusterId &&
other.id == id && other.id == id &&
other.imageHeight == imageHeight && other.imageHeight == imageHeight &&
other.imageWidth == imageWidth && other.imageWidth == imageWidth &&
other.isVisible == isVisible && other.isVisible == isVisible &&
other.personId == personId &&
other.sourceType == sourceType; other.sourceType == sourceType;
@override @override
@@ -105,15 +105,15 @@ class SyncAssetFaceV2 {
(boundingBoxY1.hashCode) + (boundingBoxY1.hashCode) +
(boundingBoxY2.hashCode) + (boundingBoxY2.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) +
(faceClusterId == null ? 0 : faceClusterId!.hashCode) +
(id.hashCode) + (id.hashCode) +
(imageHeight.hashCode) + (imageHeight.hashCode) +
(imageWidth.hashCode) + (imageWidth.hashCode) +
(isVisible.hashCode) + (isVisible.hashCode) +
(personId == null ? 0 : personId!.hashCode) +
(sourceType.hashCode); (sourceType.hashCode);
@override @override
String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, faceClusterId=$faceClusterId, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, sourceType=$sourceType]'; String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, personId=$personId, sourceType=$sourceType]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -128,16 +128,16 @@ class SyncAssetFaceV2 {
: this.deletedAt!.toUtc().toIso8601String(); : this.deletedAt!.toUtc().toIso8601String();
} else { } else {
// json[r'deletedAt'] = null; // json[r'deletedAt'] = null;
}
if (this.faceClusterId != null) {
json[r'faceClusterId'] = this.faceClusterId;
} else {
// json[r'faceClusterId'] = null;
} }
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'imageHeight'] = this.imageHeight; json[r'imageHeight'] = this.imageHeight;
json[r'imageWidth'] = this.imageWidth; json[r'imageWidth'] = this.imageWidth;
json[r'isVisible'] = this.isVisible; json[r'isVisible'] = this.isVisible;
if (this.personId != null) {
json[r'personId'] = this.personId;
} else {
// json[r'personId'] = null;
}
json[r'sourceType'] = this.sourceType; json[r'sourceType'] = this.sourceType;
return json; return json;
} }
@@ -157,11 +157,11 @@ class SyncAssetFaceV2 {
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!, boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!, boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
faceClusterId: mapValueOfType<String>(json, r'faceClusterId'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!, imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!, imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
isVisible: mapValueOfType<bool>(json, r'isVisible')!, isVisible: mapValueOfType<bool>(json, r'isVisible')!,
personId: mapValueOfType<String>(json, r'personId'),
sourceType: mapValueOfType<String>(json, r'sourceType')!, sourceType: mapValueOfType<String>(json, r'sourceType')!,
); );
} }
@@ -216,11 +216,11 @@ class SyncAssetFaceV2 {
'boundingBoxY1', 'boundingBoxY1',
'boundingBoxY2', 'boundingBoxY2',
'deletedAt', 'deletedAt',
'faceClusterId',
'id', 'id',
'imageHeight', 'imageHeight',
'imageWidth', 'imageWidth',
'isVisible', 'isVisible',
'personId',
'sourceType', 'sourceType',
}; };
} }
-107
View File
@@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UpdateSharingOptionsDto {
/// Returns a new [UpdateSharingOptionsDto] instance.
UpdateSharingOptionsDto({
required this.inTimeline,
this.permissions = const [],
});
bool inTimeline;
List<SharingPermission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateSharingOptionsDto &&
other.inTimeline == inTimeline &&
_deepEquality.equals(other.permissions, permissions);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(inTimeline.hashCode) +
(permissions.hashCode);
@override
String toString() => 'UpdateSharingOptionsDto[inTimeline=$inTimeline, permissions=$permissions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'inTimeline'] = this.inTimeline;
json[r'permissions'] = this.permissions;
return json;
}
/// Returns a new [UpdateSharingOptionsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateSharingOptionsDto? fromJson(dynamic value) {
upgradeDto(value, "UpdateSharingOptionsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return UpdateSharingOptionsDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<UpdateSharingOptionsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateSharingOptionsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateSharingOptionsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateSharingOptionsDto> mapFromJson(dynamic json) {
final map = <String, UpdateSharingOptionsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateSharingOptionsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateSharingOptionsDto-objects as value to a dart map
static Map<String, List<UpdateSharingOptionsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateSharingOptionsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UpdateSharingOptionsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'inTimeline',
'permissions',
};
}
+10 -193
View File
@@ -2277,121 +2277,6 @@
"x-immich-permission": "album.read" "x-immich-permission": "album.read"
} }
}, },
"/albums/{id}/user/self": {
"get": {
"description": "Get the own sharing permissions in a specific album.",
"operationId": "getOwnAlbumUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharingOptionsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get own sharing permissions",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Stable"
}
],
"x-immich-permission": "albumAsset.create",
"x-immich-state": "Stable"
},
"put": {
"description": "Change the own sharing permissions in a specific album.",
"operationId": "updateOwnAlbumUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSharingOptionsDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update own sharing permissions",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Stable"
}
],
"x-immich-permission": "albumAsset.create",
"x-immich-state": "Stable"
}
},
"/albums/{id}/user/{userId}": { "/albums/{id}/user/{userId}": {
"delete": { "delete": {
"description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.", "description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.",
@@ -8460,7 +8345,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/MergeFaceClusterDto" "$ref": "#/components/schemas/MergePersonDto"
} }
} }
}, },
@@ -17057,12 +16942,6 @@
}, },
"type": "array" "type": "array"
}, },
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
},
"resized": { "resized": {
"description": "Is resized", "description": "Is resized",
"type": "boolean", "type": "boolean",
@@ -17134,7 +17013,6 @@
"originalFileName", "originalFileName",
"originalPath", "originalPath",
"ownerId", "ownerId",
"permissions",
"thumbhash", "thumbhash",
"type", "type",
"updatedAt", "updatedAt",
@@ -18194,7 +18072,6 @@
"DatabaseBackup", "DatabaseBackup",
"FacialRecognitionQueueAll", "FacialRecognitionQueueAll",
"FacialRecognition", "FacialRecognition",
"FacialRecognitionMerge",
"FileDelete", "FileDelete",
"FileMigrationQueueAll", "FileMigrationQueueAll",
"LibraryDeleteCheck", "LibraryDeleteCheck",
@@ -18604,8 +18481,7 @@
"user-cleanup", "user-cleanup",
"memory-cleanup", "memory-cleanup",
"memory-create", "memory-create",
"backup-database", "backup-database"
"person-group-merge"
], ],
"type": "string" "type": "string"
}, },
@@ -18931,10 +18807,10 @@
}, },
"type": "object" "type": "object"
}, },
"MergeFaceClusterDto": { "MergePersonDto": {
"properties": { "properties": {
"ids": { "ids": {
"description": "Face cluster IDs to merge", "description": "Person IDs to merge",
"items": { "items": {
"format": "uuid", "format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -19959,11 +19835,6 @@
], ],
"x-immich-state": "Stable" "x-immich-state": "Stable"
}, },
"faceClusterId": {
"description": "Face cluster ID",
"nullable": true,
"type": "string"
},
"id": { "id": {
"description": "Person ID", "description": "Person ID",
"type": "string" "type": "string"
@@ -20014,7 +19885,6 @@
}, },
"required": [ "required": [
"birthDate", "birthDate",
"faceClusterId",
"id", "id",
"isHidden", "isHidden",
"name", "name",
@@ -21927,41 +21797,6 @@
}, },
"type": "object" "type": "object"
}, },
"SharingOptionsResponseDto": {
"properties": {
"inTimeline": {
"type": "boolean"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
}
},
"required": [
"inTimeline",
"permissions"
],
"type": "object"
},
"SharingPermission": {
"description": "Sharing permission schema",
"enum": [
"all",
"asset.read",
"asset.update",
"asset.edit",
"asset.delete",
"asset.share",
"exif.read",
"person.read",
"person.update",
"person.merge",
"person.delete"
],
"type": "string"
},
"SignUpDto": { "SignUpDto": {
"properties": { "properties": {
"email": { "email": {
@@ -23058,11 +22893,6 @@
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string" "type": "string"
}, },
"faceClusterId": {
"description": "Person ID",
"nullable": true,
"type": "string"
},
"id": { "id": {
"description": "Asset face ID", "description": "Asset face ID",
"type": "string" "type": "string"
@@ -23083,6 +22913,11 @@
"description": "Is the face visible in the asset", "description": "Is the face visible in the asset",
"type": "boolean" "type": "boolean"
}, },
"personId": {
"description": "Person ID",
"nullable": true,
"type": "string"
},
"sourceType": { "sourceType": {
"description": "Source type", "description": "Source type",
"type": "string" "type": "string"
@@ -23095,11 +22930,11 @@
"boundingBoxY1", "boundingBoxY1",
"boundingBoxY2", "boundingBoxY2",
"deletedAt", "deletedAt",
"faceClusterId",
"id", "id",
"imageHeight", "imageHeight",
"imageWidth", "imageWidth",
"isVisible", "isVisible",
"personId",
"sourceType" "sourceType"
], ],
"type": "object" "type": "object"
@@ -25591,24 +25426,6 @@
}, },
"type": "object" "type": "object"
}, },
"UpdateSharingOptionsDto": {
"properties": {
"inTimeline": {
"type": "boolean"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
}
},
"required": [
"inTimeline",
"permissions"
],
"type": "object"
},
"UsageByUserDto": { "UsageByUserDto": {
"properties": { "properties": {
"photos": { "photos": {
+8 -60
View File
@@ -555,14 +555,6 @@ export type MapMarkerResponseDto = {
/** State/Province name */ /** State/Province name */
state: string | null; state: string | null;
}; };
export type SharingOptionsResponseDto = {
inTimeline: boolean;
permissions: SharingPermission[];
};
export type UpdateSharingOptionsDto = {
inTimeline: boolean;
permissions: SharingPermission[];
};
export type UpdateAlbumUserDto = { export type UpdateAlbumUserDto = {
role: AlbumUserRole; role: AlbumUserRole;
}; };
@@ -800,8 +792,6 @@ export type PersonResponseDto = {
birthDate: string | null; birthDate: string | null;
/** Person color (hex) */ /** Person color (hex) */
color?: string; color?: string;
/** Face cluster ID */
faceClusterId: string | null;
/** Person ID */ /** Person ID */
id: string; id: string;
/** Is favorite */ /** Is favorite */
@@ -885,7 +875,6 @@ export type AssetResponseDto = {
/** Owner user ID */ /** Owner user ID */
ownerId: string; ownerId: string;
people?: PersonResponseDto[]; people?: PersonResponseDto[];
permissions: SharingPermission[];
/** Is resized */ /** Is resized */
resized?: boolean; resized?: boolean;
stack?: (AssetStackResponseDto) | null; stack?: (AssetStackResponseDto) | null;
@@ -1471,8 +1460,8 @@ export type PersonUpdateDto = {
/** Person name */ /** Person name */
name?: string; name?: string;
}; };
export type MergeFaceClusterDto = { export type MergePersonDto = {
/** Face cluster IDs to merge */ /** Person IDs to merge */
ids: string[]; ids: string[];
}; };
export type AssetFaceUpdateItem = { export type AssetFaceUpdateItem = {
@@ -2933,8 +2922,6 @@ export type SyncAssetFaceV2 = {
boundingBoxY2: number; boundingBoxY2: number;
/** Face deleted at */ /** Face deleted at */
deletedAt: string | null; deletedAt: string | null;
/** Person ID */
faceClusterId: string | null;
/** Asset face ID */ /** Asset face ID */
id: string; id: string;
/** Image height */ /** Image height */
@@ -2943,6 +2930,8 @@ export type SyncAssetFaceV2 = {
imageWidth: number; imageWidth: number;
/** Is the face visible in the asset */ /** Is the face visible in the asset */
isVisible: boolean; isVisible: boolean;
/** Person ID */
personId: string | null;
/** Source type */ /** Source type */
sourceType: string; sourceType: string;
}; };
@@ -3738,32 +3727,6 @@ export function getAlbumMapMarkers({ id, key, slug }: {
...opts ...opts
})); }));
} }
/**
* Get own sharing permissions
*/
export function getOwnAlbumUser({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SharingOptionsResponseDto;
}>(`/albums/${encodeURIComponent(id)}/user/self`, {
...opts
}));
}
/**
* Update own sharing permissions
*/
export function updateOwnAlbumUser({ id, updateSharingOptionsDto }: {
id: string;
updateSharingOptionsDto: UpdateSharingOptionsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/albums/${encodeURIComponent(id)}/user/self`, oazapfts.json({
...opts,
method: "PUT",
body: updateSharingOptionsDto
})));
}
/** /**
* Remove user from album * Remove user from album
*/ */
@@ -5168,9 +5131,9 @@ export function updatePerson({ id, personUpdateDto }: {
/** /**
* Merge people * Merge people
*/ */
export function mergePerson({ id, mergeFaceClusterDto }: { export function mergePerson({ id, mergePersonDto }: {
id: string; id: string;
mergeFaceClusterDto: MergeFaceClusterDto; mergePersonDto: MergePersonDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
@@ -5178,7 +5141,7 @@ export function mergePerson({ id, mergeFaceClusterDto }: {
}>(`/people/${encodeURIComponent(id)}/merge`, oazapfts.json({ }>(`/people/${encodeURIComponent(id)}/merge`, oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: mergeFaceClusterDto body: mergePersonDto
}))); })));
} }
/** /**
@@ -6825,19 +6788,6 @@ export enum BulkIdErrorReason {
Unknown = "unknown", Unknown = "unknown",
Validation = "validation" Validation = "validation"
} }
export enum SharingPermission {
All = "all",
AssetRead = "asset.read",
AssetUpdate = "asset.update",
AssetEdit = "asset.edit",
AssetDelete = "asset.delete",
AssetShare = "asset.share",
ExifRead = "exif.read",
PersonRead = "person.read",
PersonUpdate = "person.update",
PersonMerge = "person.merge",
PersonDelete = "person.delete"
}
export enum Permission { export enum Permission {
All = "all", All = "all",
ActivityCreate = "activity.create", ActivityCreate = "activity.create",
@@ -7045,8 +6995,7 @@ export enum ManualJobName {
UserCleanup = "user-cleanup", UserCleanup = "user-cleanup",
MemoryCleanup = "memory-cleanup", MemoryCleanup = "memory-cleanup",
MemoryCreate = "memory-create", MemoryCreate = "memory-create",
BackupDatabase = "backup-database", BackupDatabase = "backup-database"
PersonGroupMerge = "person-group-merge"
} }
export enum QueueName { export enum QueueName {
ThumbnailGeneration = "thumbnailGeneration", ThumbnailGeneration = "thumbnailGeneration",
@@ -7123,7 +7072,6 @@ export enum JobName {
DatabaseBackup = "DatabaseBackup", DatabaseBackup = "DatabaseBackup",
FacialRecognitionQueueAll = "FacialRecognitionQueueAll", FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
FacialRecognition = "FacialRecognition", FacialRecognition = "FacialRecognition",
FacialRecognitionMerge = "FacialRecognitionMerge",
FileDelete = "FileDelete", FileDelete = "FileDelete",
FileMigrationQueueAll = "FileMigrationQueueAll", FileMigrationQueueAll = "FileMigrationQueueAll",
LibraryDeleteCheck = "LibraryDeleteCheck", LibraryDeleteCheck = "LibraryDeleteCheck",
+5 -5
View File
@@ -758,8 +758,8 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../packages/sdk version: link:../packages/sdk
'@immich/ui': '@immich/ui':
specifier: ^0.77.0 specifier: ^0.79.0
version: 0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4)) version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
'@mapbox/mapbox-gl-rtl-text': '@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0 specifier: 0.4.0
version: 0.4.0 version: 0.4.0
@@ -3204,8 +3204,8 @@ packages:
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
hasBin: true hasBin: true
'@immich/ui@0.77.3': '@immich/ui@0.79.0':
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==} resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
peerDependencies: peerDependencies:
'@sveltejs/kit': ^2.13.0 '@sveltejs/kit': ^2.13.0
svelte: ^5.0.0 svelte: ^5.0.0
@@ -15879,7 +15879,7 @@ snapshots:
pg-connection-string: 2.13.0 pg-connection-string: 2.13.0
postgres: 3.4.9 postgres: 3.4.9
'@immich/ui@0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))': '@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
dependencies: dependencies:
'@internationalized/date': 3.12.1 '@internationalized/date': 3.12.1
'@mdi/js': 7.4.47 '@mdi/js': 7.4.47
+2 -2
View File
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
COPY --from=server /output/server-pruned ./server COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE
@@ -11,7 +11,6 @@ import {
GetAlbumsDto, GetAlbumsDto,
UpdateAlbumDto, UpdateAlbumDto,
UpdateAlbumUserDto, UpdateAlbumUserDto,
UpdateSharingPermissionsDto as UpdateSharingOptionsDto,
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@@ -166,33 +165,6 @@ export class AlbumController {
return this.service.addUsers(auth, id, dto); return this.service.addUsers(auth, id, dto);
} }
@Get(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@Endpoint({
summary: 'Get own sharing permissions',
description: 'Get the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
getOwnAlbumUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.getSelf(auth, id);
}
@Put(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update own sharing permissions',
description: 'Change the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
updateOwnAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateSharingOptionsDto,
): Promise<void> {
return this.service.updateSelf(auth, id, dto);
}
@Put(':id/user/:userId') @Put(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserUpdate }) @Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
+2 -2
View File
@@ -19,7 +19,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
AssetFaceUpdateDto, AssetFaceUpdateDto,
MergeFaceClusterDto, MergePersonDto,
PeopleResponseDto, PeopleResponseDto,
PeopleUpdateDto, PeopleUpdateDto,
PersonCreateDto, PersonCreateDto,
@@ -182,7 +182,7 @@ export class PersonController {
mergePerson( mergePerson(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Body() dto: MergeFaceClusterDto, @Body() dto: MergePersonDto,
): Promise<BulkIdResponseDto[]> { ): Promise<BulkIdResponseDto[]> {
return this.service.mergePerson(auth, id, dto); return this.service.mergePerson(auth, id, dto);
} }
+1 -4
View File
@@ -9,7 +9,6 @@ import {
MemoryType, MemoryType,
Permission, Permission,
SharedLinkType, SharedLinkType,
SharingPermission,
SourceType, SourceType,
UserAvatarColor, UserAvatarColor,
UserStatus, UserStatus,
@@ -210,7 +209,6 @@ export type Partner = {
updatedAt: Date; updatedAt: Date;
updateId: string; updateId: string;
inTimeline: boolean; inTimeline: boolean;
permissions: SharingPermission[];
}; };
export type Place = { export type Place = {
@@ -254,7 +252,6 @@ export type Person = {
faceAssetId: string | null; faceAssetId: string | null;
isHidden: boolean; isHidden: boolean;
thumbnailPath: string; thumbnailPath: string;
faceClusterId: string | null;
}; };
export type AssetFace = { export type AssetFace = {
@@ -267,7 +264,7 @@ export type AssetFace = {
boundingBoxY2: number; boundingBoxY2: number;
imageHeight: number; imageHeight: number;
imageWidth: number; imageWidth: number;
faceClusterId: string | null; personId: string | null;
sourceType: SourceType; sourceType: SourceType;
person?: ShallowDehydrateObject<Person> | null; person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date; updatedAt: Date;
+2 -12
View File
@@ -3,8 +3,8 @@ import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink } from 'src/database'; import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { mapUser, UserResponseSchema } from 'src/dtos/user.dto'; import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema, SharingPermissionSchema } from 'src/enum'; import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types'; import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date'; import { asDateString } from 'src/utils/date';
import { stringToBool } from 'src/validation'; import { stringToBool } from 'src/validation';
@@ -63,14 +63,6 @@ const UpdateAlbumSchema = z
}) })
.meta({ id: 'UpdateAlbumDto' }); .meta({ id: 'UpdateAlbumDto' });
const UpdateSharingOptionsSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'UpdateSharingOptionsDto' });
const SharingOptionsResponseSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'SharingOptionsResponseDto' });
const GetAlbumsSchema = z const GetAlbumsSchema = z
.object({ .object({
isOwned: stringToBool isOwned: stringToBool
@@ -155,8 +147,6 @@ export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {}
export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {} export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {}
export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {} export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {}
export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {} export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {}
export class UpdateSharingPermissionsDto extends createZodDto(UpdateSharingOptionsSchema) {}
export class SharingPermissionsResponseDto extends createZodDto(SharingOptionsResponseSchema) {}
export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {} export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {}
class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {} class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {}
+1 -16
View File
@@ -15,8 +15,6 @@ import {
AssetVisibility, AssetVisibility,
AssetVisibilitySchema, AssetVisibilitySchema,
ChecksumAlgorithm, ChecksumAlgorithm,
SharingPermission,
SharingPermissionSchema,
} from 'src/enum'; } from 'src/enum';
import { MaybeDehydrated } from 'src/types'; import { MaybeDehydrated } from 'src/types';
import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -47,7 +45,6 @@ const SanitizedAssetResponseSchema = z
hasMetadata: z.boolean().describe('Whether asset has metadata'), hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.int().min(0).nullable().describe('Asset width'), width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'), height: z.int().min(0).nullable().describe('Asset height'),
permissions: z.array(SharingPermissionSchema),
}) })
.meta({ id: 'SanitizedAssetResponseDto' }); .meta({ id: 'SanitizedAssetResponseDto' });
@@ -116,7 +113,6 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
.boolean() .boolean()
.describe('Is edited') .describe('Is edited')
.meta(new HistoryBuilder().added('v2.5.0').beta('v2.5.0').getExtensions()), .meta(new HistoryBuilder().added('v2.5.0').beta('v2.5.0').getExtensions()),
permissions: z.array(SharingPermissionSchema),
}).shape, }).shape,
).meta({ id: 'AssetResponseDto' }); ).meta({ id: 'AssetResponseDto' });
@@ -158,7 +154,6 @@ export type MapAsset = {
width: number | null; width: number | null;
height: number | null; height: number | null;
isEdited: boolean; isEdited: boolean;
permissions?: { permission: SharingPermission }[];
}; };
export type AssetMapOptions = { export type AssetMapOptions = {
@@ -197,16 +192,8 @@ const mapStack = (entity: { stack?: Stack | null }) => {
export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto { export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options; const { stripMetadata = false, withStack = false } = options;
const permissions =
options.auth?.user.id === entity.ownerId
? [SharingPermission.All]
: (entity.permissions?.map(({ permission }) => permission) ?? []);
if ( if (stripMetadata) {
stripMetadata ||
(entity.permissions &&
!(permissions.includes(SharingPermission.All) || permissions.includes(SharingPermission.ExifRead)))
) {
const sanitizedAssetResponse: SanitizedAssetResponseDto = { const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id, id: entity.id,
type: entity.type, type: entity.type,
@@ -218,7 +205,6 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
hasMetadata: false, hasMetadata: false,
width: entity.width, width: entity.width,
height: entity.height, height: entity.height,
permissions,
}; };
return sanitizedAssetResponse as AssetResponseDto; return sanitizedAssetResponse as AssetResponseDto;
} }
@@ -256,6 +242,5 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
width: entity.width, width: entity.width,
height: entity.height, height: entity.height,
isEdited: entity.isEdited, isEdited: entity.isEdited,
permissions,
}; };
} }
+7 -7
View File
@@ -2,6 +2,7 @@ import { Selectable } from 'kysely';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { AssetFace, Person } from 'src/database'; import { AssetFace, Person } from 'src/database';
import { HistoryBuilder } from 'src/decorators'; import { HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SourceTypeSchema } from 'src/enum'; import { SourceTypeSchema } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
@@ -39,11 +40,11 @@ const PeopleUpdateSchema = z
}) })
.meta({ id: 'PeopleUpdateDto' }); .meta({ id: 'PeopleUpdateDto' });
const MergeFaceClusterSchema = z const MergePersonSchema = z
.object({ .object({
ids: z.array(z.uuidv4()).describe('Face cluster IDs to merge'), ids: z.array(z.uuidv4()).describe('Person IDs to merge'),
}) })
.meta({ id: 'MergeFaceClusterDto' }); .meta({ id: 'MergePersonDto' });
const PersonSearchSchema = z const PersonSearchSchema = z
.object({ .object({
@@ -80,14 +81,13 @@ export const PersonResponseSchema = z
.optional() .optional()
.describe('Person color (hex)') .describe('Person color (hex)')
.meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()), .meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()),
faceClusterId: z.string().nullable().describe('Face cluster ID'),
}) })
.meta({ id: 'PersonResponseDto' }); .meta({ id: 'PersonResponseDto' });
export class PersonCreateDto extends createZodDto(PersonCreateSchema) {} export class PersonCreateDto extends createZodDto(PersonCreateSchema) {}
export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {} export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {}
export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {} export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {}
export class MergeFaceClusterDto extends createZodDto(MergeFaceClusterSchema) {} export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {} export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {} export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
@@ -179,7 +179,6 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
isFavorite: person.isFavorite, isFavorite: person.isFavorite,
color: person.color ?? undefined, color: person.color ?? undefined,
updatedAt: asDateString(person.updatedAt), updatedAt: asDateString(person.updatedAt),
faceClusterId: person.faceClusterId,
}; };
} }
@@ -208,11 +207,12 @@ function mapFacesWithoutPerson(
export function mapFaces( export function mapFaces(
face: AssetFace, face: AssetFace,
auth: AuthDto,
edits?: AssetEditActionItem[], edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions, assetDimensions?: ImageDimensions,
): AssetFaceResponseDto { ): AssetFaceResponseDto {
return { return {
...mapFacesWithoutPerson(face, edits, assetDimensions), ...mapFacesWithoutPerson(face, edits, assetDimensions),
person: face.person ? mapPerson(face.person) : null, person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
}; };
} }
+4 -7
View File
@@ -374,13 +374,10 @@ const SyncAssetFaceV1Schema = z
}) })
.meta({ id: 'SyncAssetFaceV1' }); .meta({ id: 'SyncAssetFaceV1' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.omit({ personId: true }) const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({
.extend({ deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'), isVisible: z.boolean().describe('Is the face visible in the asset'),
isVisible: z.boolean().describe('Is the face visible in the asset'), }).meta({ id: 'SyncAssetFaceV2' });
faceClusterId: z.string().nullable().describe('Person ID'),
})
.meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceDeleteV1Schema = z const SyncAssetFaceDeleteV1Schema = z
.object({ assetFaceId: z.string().describe('Asset face ID') }) .object({ assetFaceId: z.string().describe('Asset face ID') })
-24
View File
@@ -306,28 +306,6 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll', AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
} }
export enum SharingPermission {
All = 'all',
AssetRead = 'asset.read',
AssetUpdate = 'asset.update',
AssetEdit = 'asset.edit',
AssetDelete = 'asset.delete',
AssetShare = 'asset.share',
ExifRead = 'exif.read',
PersonRead = 'person.read',
PersonUpdate = 'person.update',
PersonMerge = 'person.merge',
PersonDelete = 'person.delete',
}
export const SharingPermissionSchema = z
.enum(SharingPermission)
.describe('Sharing permission schema')
.meta({ id: 'SharingPermission' });
export enum SharedLinkType { export enum SharedLinkType {
Album = 'ALBUM', Album = 'ALBUM',
@@ -426,7 +404,6 @@ export enum ManualJobName {
MemoryCleanup = 'memory-cleanup', MemoryCleanup = 'memory-cleanup',
MemoryCreate = 'memory-create', MemoryCreate = 'memory-create',
BackupDatabase = 'backup-database', BackupDatabase = 'backup-database',
PersonGroupMerge = 'person-group-merge',
} }
export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' }); export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' });
@@ -836,7 +813,6 @@ export enum JobName {
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll', FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
FacialRecognition = 'FacialRecognition', FacialRecognition = 'FacialRecognition',
FacialRecognitionMerge = 'FacialRecognitionMerge',
FileDelete = 'FileDelete', FileDelete = 'FileDelete',
FileMigrationQueueAll = 'FileMigrationQueueAll', FileMigrationQueueAll = 'FileMigrationQueueAll',
-34
View File
@@ -149,40 +149,6 @@ where
"albumAssets"."livePhotoVideoId" "albumAssets"."livePhotoVideoId"
] && array[$2]::uuid[] ] && array[$2]::uuid[]
-- AccessRepository.asset.checkSharedAccess
select
"album_asset"."assetId"
from
"album_asset"
inner join "album_user" on "album_asset"."albumId" = "album_user"."albumId"
and "album_user"."userId" = $1
where
"album_asset"."assetId" in ($2)
and "album_asset"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
(
"album_user"."permissions" @> $3::sharing_permission_enum[]
or $4 = any ("album_user"."permissions")
)
)
union
select
"asset"."id" as "assetId"
from
"partner"
inner join "asset" on "asset"."ownerId" = "partner"."sharedById"
and "asset"."id" in ($5)
where
"partner"."sharedWithId" = $6
and (
"partner"."permissions" @> $7::sharing_permission_enum[]
or $8 = any ("partner"."permissions")
)
-- AccessRepository.authDevice.checkOwnerAccess -- AccessRepository.authDevice.checkOwnerAccess
select select
"session"."id" "session"."id"
+14 -52
View File
@@ -182,25 +182,18 @@ select
from from
( (
select select
( "asset_face".*,
select "person" as "person"
to_json(obj)
from
(
select
"person".*
from
"face_cluster"
inner join "person" on "person"."faceClusterId" = "face_cluster"."id"
where
"face_cluster"."id" = "asset_face"."faceClusterId"
limit
$1
) as obj
) as "person",
"asset_face".*
from from
"asset_face" "asset_face"
left join lateral (
select
"person".*
from
"person"
where
"asset_face"."personId" = "person"."id"
) as "person" on true
where where
"asset_face"."assetId" = "asset"."id" "asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
@@ -231,7 +224,7 @@ from
"asset" "asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."id" = any ($2::uuid[]) "asset"."id" = any ($1::uuid[])
-- AssetRepository.deleteAll -- AssetRepository.deleteAll
delete from "asset" delete from "asset"
@@ -297,44 +290,13 @@ limit
-- AssetRepository.getById -- AssetRepository.getById
select select
"asset".*, "asset".*
(
select
coalesce(json_agg(agg), '[]')
from
(
select distinct
unnest("album_user"."permissions") as "permission"
from
"album_user"
inner join "album_asset" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."userId" = "asset"."ownerId"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $1
)
union
select distinct
unnest("partner"."permissions") as "permission"
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
) as agg
) as "permissions"
from from
"asset" "asset"
where where
"asset"."id" = $3::uuid "asset"."id" = $1::uuid
limit limit
$4 $2
-- AssetRepository.updateAll -- AssetRepository.updateAll
update "asset" update "asset"
+2 -2
View File
@@ -47,7 +47,7 @@ select
$1 as "one" $1 as "one"
from from
"asset_face" "asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" inner join "person" on "person"."id" = "asset_face"."personId"
where where
"asset_face"."assetId" = "asset"."id" "asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2 and "person"."isHidden" = $2
@@ -86,7 +86,7 @@ select
$1 as "one" $1 as "one"
from from
"asset_face" "asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" inner join "person" on "person"."id" = "asset_face"."personId"
where where
"asset_face"."assetId" = "asset"."id" "asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2 and "person"."isHidden" = $2
+33 -225
View File
@@ -3,6 +3,9 @@
-- PersonRepository.reassignFaces -- PersonRepository.reassignFaces
update "asset_face" update "asset_face"
set set
"personId" = $1
where
"asset_face"."personId" = $2
-- PersonRepository.delete -- PersonRepository.delete
delete from "person" delete from "person"
@@ -21,64 +24,27 @@ limit
3 3
-- PersonRepository.getAllForUser -- PersonRepository.getAllForUser
select distinct select
on ("person"."faceClusterId") "person".* "person".*
from from
"person" "person"
inner join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId" inner join "asset_face" on "asset_face"."personId" = "person"."id"
inner join "asset" on "asset_face"."assetId" = "asset"."id" inner join "asset" on "asset_face"."assetId" = "asset"."id"
and "asset"."visibility" = 'timeline' and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
where where
( "person"."ownerId" = $1
"person"."ownerId" = $1
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $5
)
and "album_user"."userId" = "person"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true and "asset_face"."isVisible" is true
and "person"."isHidden" = $8 and "person"."isHidden" = $2
group by group by
"person"."id" "person"."id"
having having
( (
"person"."name" != $9 "person"."name" != $3
or count("asset_face"."assetId") >= $10 or count("asset_face"."assetId") >= $4
) )
order by order by
"person"."faceClusterId",
"person"."ownerId" = $11 desc,
"person"."isHidden" asc, "person"."isHidden" asc,
"person"."isFavorite" desc, "person"."isFavorite" desc,
NULLIF(person.name, '') is null asc, NULLIF(person.name, '') is null asc,
@@ -86,16 +52,16 @@ order by
NULLIF(person.name, '') asc nulls last, NULLIF(person.name, '') asc nulls last,
"person"."createdAt" "person"."createdAt"
limit limit
$12 $5
offset offset
$13 $6
-- PersonRepository.getAllWithoutFaces -- PersonRepository.getAllWithoutFaces
select select
"person".* "person".*
from from
"person" "person"
left join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId" left join "asset_face" on "asset_face"."personId" = "person"."id"
where where
"asset_face"."deletedAt" is null "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true and "asset_face"."isVisible" is true
@@ -117,26 +83,15 @@ select
from from
"person" "person"
where where
"person"."faceClusterId" = "asset_face"."faceClusterId" "person"."id" = "asset_face"."personId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
) as obj ) as obj
) as "person" ) as "person"
from from
"asset_face" "asset_face"
where where
"asset_face"."assetId" = $2 "asset_face"."assetId" = $1
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3 and "asset_face"."isVisible" = $2
order by order by
"asset_face"."boundingBoxX1" asc "asset_face"."boundingBoxX1" asc
@@ -153,30 +108,19 @@ select
from from
"person" "person"
where where
"person"."faceClusterId" = "asset_face"."faceClusterId" "person"."id" = "asset_face"."personId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
) as obj ) as obj
) as "person" ) as "person"
from from
"asset_face" "asset_face"
where where
"asset_face"."id" = $2 "asset_face"."id" = $1
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
-- PersonRepository.getFaceForFacialRecognitionJob -- PersonRepository.getFaceForFacialRecognitionJob
select select
"asset_face"."id", "asset_face"."id",
"asset_face"."faceClusterId", "asset_face"."personId",
"asset_face"."sourceType", "asset_face"."sourceType",
( (
select select
@@ -246,7 +190,7 @@ where
-- PersonRepository.reassignFace -- PersonRepository.reassignFace
update "asset_face" update "asset_face"
set set
"faceClusterId" = $1 "personId" = $1
where where
"asset_face"."id" = $2 "asset_face"."id" = $2
@@ -265,10 +209,9 @@ where
"person"."ownerId" = $1 "person"."ownerId" = $1
and f_unaccent ("person"."name") %> f_unaccent ($2) and f_unaccent ("person"."name") %> f_unaccent ($2)
order by order by
f_unaccent ("person"."name") <->>> f_unaccent ($3), f_unaccent ("person"."name") <->>> f_unaccent ($3)
"person"."ownerId" = $4 desc
limit limit
$5 $4
-- PersonRepository.getDistinctNames -- PersonRepository.getDistinctNames
select distinct select distinct
@@ -291,52 +234,9 @@ from
and "asset"."visibility" = 'timeline' and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
where where
( "asset_face"."deletedAt" is null
"asset"."ownerId" = $1
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $5
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true and "asset_face"."isVisible" is true
and "asset_face"."faceClusterId" = ( and "asset_face"."personId" = $1
select
"person"."faceClusterId"
from
"person"
where
"person"."id" = $8
)
-- PersonRepository.getNumberOfPeople -- PersonRepository.getNumberOfPeople
select select
@@ -356,7 +256,7 @@ where
from from
"asset_face" "asset_face"
where where
"asset_face"."faceClusterId" = "person"."faceClusterId" "asset_face"."personId" = "person"."id"
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2 and "asset_face"."isVisible" = $2
and exists ( and exists (
@@ -369,42 +269,7 @@ where
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
) )
) )
and ( and "person"."ownerId" = $3
"person"."ownerId" = $3
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $4
and (
$5 = any ("partner"."permissions")
or "partner"."permissions" @> $6
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $7
)
and "album_user"."userId" = "person"."ownerId"
and (
$8 = any ("album_user"."permissions")
or "album_user"."permissions" @> $9
)
)
)
)
-- PersonRepository.refreshFaces -- PersonRepository.refreshFaces
with with
@@ -434,26 +299,14 @@ select
from from
"person" "person"
where where
"person"."faceClusterId" = "asset_face"."faceClusterId" "person"."id" = "asset_face"."personId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
) as obj ) as obj
) as "person" ) as "person"
from from
"asset_face" "asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
where where
"person"."id" in ($2) "asset_face"."assetId" in ($1)
and "asset_face"."assetId" in ($3) and "asset_face"."personId" in ($2)
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
-- PersonRepository.getRandomFace -- PersonRepository.getRandomFace
@@ -461,52 +314,8 @@ select
"asset_face".* "asset_face".*
from from
"asset_face" "asset_face"
inner join "person" on "asset_face"."faceClusterId" = "person"."faceClusterId"
and "person"."id" = $1
where where
"asset_face"."assetId" in ( "asset_face"."personId" = $1
select
"asset"."id"
from
"asset"
where
(
"asset"."ownerId" = "person"."ownerId"
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = "person"."ownerId"
and (
$2 = any ("partner"."permissions")
or "partner"."permissions" @> $3
)
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = "person"."ownerId"
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$4 = any ("album_user"."permissions")
or "album_user"."permissions" @> $5
)
)
)
)
)
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true and "asset_face"."isVisible" is true
@@ -542,9 +351,8 @@ select
"asset_face"."id" "asset_face"."id"
from from
"asset_face" "asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
and "person"."id" = $1
inner join "asset" on "asset"."id" = "asset_face"."assetId" inner join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."isOffline" = $2 and "asset"."isOffline" = $1
where where
"asset_face"."assetId" = $3 "asset_face"."assetId" = $2
and "asset_face"."personId" = $3
+22 -222
View File
@@ -10,52 +10,15 @@ where
"asset"."visibility" = $1 "asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2 and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3 and "asset_exif"."lensModel" = $3
and ( and "asset"."ownerId" = any ($4::uuid[])
"asset"."ownerId" = $4 and "asset"."isFavorite" = $5
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
"asset"."fileCreatedAt" desc "asset"."fileCreatedAt" desc
limit limit
$14 $6
offset offset
$15 $7
-- SearchRepository.searchStatistics -- SearchRepository.searchStatistics
select select
@@ -67,45 +30,8 @@ where
"asset"."visibility" = $1 "asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2 and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3 and "asset_exif"."lensModel" = $3
and ( and "asset"."ownerId" = any ($4::uuid[])
"asset"."ownerId" = $4 and "asset"."isFavorite" = $5
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
-- SearchRepository.searchRandom -- SearchRepository.searchRandom
@@ -118,50 +44,13 @@ where
"asset"."visibility" = $1 "asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2 and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3 and "asset_exif"."lensModel" = $3
and ( and "asset"."ownerId" = any ($4::uuid[])
"asset"."ownerId" = $4 and "asset"."isFavorite" = $5
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
random() random()
limit limit
$14 $6
-- SearchRepository.searchLargeAssets -- SearchRepository.searchLargeAssets
select select
@@ -174,51 +63,14 @@ where
"asset"."visibility" = $1 "asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2 and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3 and "asset_exif"."lensModel" = $3
and ( and "asset"."ownerId" = any ($4::uuid[])
"asset"."ownerId" = $4 and "asset"."isFavorite" = $5
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $14 and "asset_exif"."fileSizeInByte" > $6
order by order by
"asset_exif"."fileSizeInByte" desc "asset_exif"."fileSizeInByte" desc
limit limit
$15 $7
-- SearchRepository.searchSmart -- SearchRepository.searchSmart
begin begin
@@ -234,52 +86,15 @@ where
"asset"."visibility" = $1 "asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2 and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3 and "asset_exif"."lensModel" = $3
and ( and "asset"."ownerId" = any ($4::uuid[])
"asset"."ownerId" = $4 and "asset"."isFavorite" = $5
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
smart_search.embedding <=> $14 smart_search.embedding <=> $6
limit limit
$15 $7
offset offset
$16 $8
commit commit
-- SearchRepository.getEmbedding -- SearchRepository.getEmbedding
@@ -298,30 +113,15 @@ with
"cte" as ( "cte" as (
select select
"asset_face"."id", "asset_face"."id",
"asset_face"."faceClusterId", "asset_face"."personId",
face_search.embedding <=> $1 as "distance", face_search.embedding <=> $1 as "distance"
"asset"."ownerId"
from from
"asset_face" "asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId" inner join "asset" on "asset"."id" = "asset_face"."assetId"
inner join "face_search" on "face_search"."faceId" = "asset_face"."id" inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
left join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" left join "person" on "person"."id" = "asset_face"."personId"
where where
"asset"."ownerId" in ( "asset"."ownerId" = any ($2::uuid[])
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" in (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = any ($2::uuid[])
)
)
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
"distance" "distance"
+1 -1
View File
@@ -527,7 +527,7 @@ order by
select select
"asset_face"."id", "asset_face"."id",
"assetId", "assetId",
"faceClusterId", "personId",
"imageWidth", "imageWidth",
"imageHeight", "imageHeight",
"boundingBoxX1", "boundingBoxX1",
-70
View File
@@ -397,73 +397,3 @@ set
where where
"user"."deletedAt" is null "user"."deletedAt" is null
and "user"."id" = $2::uuid and "user"."id" = $2::uuid
-- UserRepository.getInSameTrustedGroup
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
-- UserRepository.mergeTrustedGroups
update "user"
set
"trustedGroupId" = "u"."trustedGroupId"
from
"user" as "u"
where
"u"."id" = $1
and "user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $2
and "user"."trustedGroupId" != "u"."trustedGroupId"
)
-- UserRepository.updateTrustedGroups
update "user"
set
"trustedGroupId" = uuid_generate_v4 ()
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
and "user"."id" != $2
and "user"."id" not in (
select
"partner"."sharedById" as "userId"
from
"partner"
where
"sharedWithId" = $3
union
select
"album_user"."userId"
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $4
)
)
+1 -74
View File
@@ -2,9 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, sql } from 'kysely'; import { Kysely, NotNull, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole, AssetVisibility, SharingPermission } from 'src/enum'; import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { hasPermissions } from 'src/repositories/person.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
@@ -275,46 +273,6 @@ class AssetAccess {
return allowedIds; return allowedIds;
}); });
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET, [SharingPermission.All]] })
async checkSharedAccess(userId: string, assetIds: Set<string>, permissions: SharingPermission[]) {
const ids = await this.db
.selectFrom('album_asset')
.select('album_asset.assetId')
.where('album_asset.assetId', 'in', [...assetIds])
.where('album_asset.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where((eb) =>
eb.or([
eb('album_user.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
]),
),
)
.innerJoin('album_user', (join) =>
join.onRef('album_asset.albumId', '=', 'album_user.albumId').on('album_user.userId', '=', userId),
)
.union((eb) =>
eb
.selectFrom('partner')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb('partner.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
]),
)
.innerJoin('asset', (join) =>
join.onRef('asset.ownerId', '=', 'partner.sharedById').on('asset.id', 'in', [...assetIds]),
)
.select('asset.id as assetId'),
)
.execute();
return new Set(ids.map(({ assetId }) => assetId));
}
} }
class AuthDeviceAccess { class AuthDeviceAccess {
@@ -494,37 +452,6 @@ class PersonAccess {
.execute() .execute()
.then((faces) => new Set(faces.map((face) => face.id))); .then((faces) => new Set(faces.map((face) => face.id)));
} }
async checkSharedAccess(userId: string, personIds: Set<string>, permissions: SharingPermission[]) {
if (personIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('person')
.select('person.id')
.where('person.id', 'in', [...personIds])
.where(hasPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
async checkSharedFaceAccess(userId: string, faceIds: Set<string>, permissions: SharingPermission[]) {
if (faceIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('asset_face')
.select('asset_face.id')
.leftJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId'))
.where('asset_face.id', 'in', [...faceIds])
.where(hasAssetPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
} }
class PartnerAccess { class PartnerAccess {
@@ -38,13 +38,4 @@ export class AlbumUserRepository {
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> { async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute(); await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
} }
get({ userId, albumId }: AlbumPermissionId) {
return this.db
.selectFrom('album_user')
.select(['permissions', 'inTimeline'])
.where('userId', '=', userId)
.where('albumId', '=', albumId)
.executeTakeFirstOrThrow();
}
} }
+5 -112
View File
@@ -8,7 +8,6 @@ import {
SelectQueryBuilder, SelectQueryBuilder,
ShallowDehydrateObject, ShallowDehydrateObject,
sql, sql,
StringReference,
Updateable, Updateable,
UpdateResult, UpdateResult,
} from 'kysely'; } from 'kysely';
@@ -18,15 +17,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database'; import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
AssetFileType,
AssetOrder,
AssetOrderBy,
AssetStatus,
AssetType,
AssetVisibility,
SharingPermission,
} from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table'; import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -50,7 +41,6 @@ import {
withFiles, withFiles,
withLibrary, withLibrary,
withOwner, withOwner,
withPermissions,
withSmartSearch, withSmartSearch,
withTagId, withTagId,
withTags, withTags,
@@ -175,93 +165,6 @@ const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T
); );
}; };
export const hasAssetPermissions =
(userId: string, permissions: SharingPermission[], ignoreTimelineVisibility: boolean = false) =>
(eb: ExpressionBuilder<DB, 'asset'>) =>
eb.or([
eb('asset.ownerId', '=', userId),
eb.exists(
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)),
),
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album_asset.albumId').on('album_user.userId', '=', userId),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true))
.where('album_user.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
),
]);
export const hasAssetPermissionsRef = <T extends keyof DB>(
eb: ExpressionBuilder<DB, 'asset'>,
userIdRef: StringReference<DB, 'asset' | T>,
permissions: SharingPermission[],
ignoreTimelineVisibility: boolean = false,
) =>
eb.or([
eb('asset.ownerId', '=', eb.ref(userIdRef as never)),
eb.exists(
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.whereRef('partner.sharedWithId', '=', userIdRef as never)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)),
),
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album_asset.albumId')
.onRef('album_user.userId', '=', userIdRef as never),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true))
.where('album_user.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
),
]);
@Injectable() @Injectable()
export class AssetRepository { export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -653,22 +556,17 @@ export class AssetRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID, {}, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getById( getById(
id: string, id: string,
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {}, { exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {},
userId?: string,
) { ) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.selectAll('asset') .selectAll('asset')
.where('asset.id', '=', asUuid(id)) .where('asset.id', '=', asUuid(id))
.$if(!!exifInfo, withExif) .$if(!!exifInfo, withExif)
.$if(!!faces, (qb) => .$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>())
qb
.select(faces?.person ? (eb) => withFacesAndPeople(eb, { userId }) : withFaces)
.$narrowType<{ faces: NotNull }>(),
)
.$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner)) .$if(!!owner, (qb) => qb.select(withOwner))
.$if(!!smartSearch, withSmartSearch) .$if(!!smartSearch, withSmartSearch)
@@ -704,7 +602,6 @@ export class AssetRepository {
.$if(!!files, (qb) => qb.select(withFiles)) .$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags)) .$if(!!tags, (qb) => qb.select(withTags))
.$if(!!edits, (qb) => qb.select(withEdits)) .$if(!!edits, (qb) => qb.select(withEdits))
.$if(!!userId, (qb) => qb.select(withPermissions(userId!)))
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -847,9 +744,7 @@ export class AssetRepository {
) )
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])), .where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
) )
.$if(!!options.userIds, (qb) => .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!)) .$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) => .$if(options.isDuplicate !== undefined, (qb) =>
@@ -934,9 +829,7 @@ export class AssetRepository {
), ),
) )
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) => .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) => .$if(!!options.withStacked, (qb) =>
qb qb
+6 -9
View File
@@ -15,7 +15,7 @@ import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/mis
type JobMapItem = { type JobMapItem = {
jobName: JobName; jobName: JobName;
queueName: QueueName; queueName: QueueName;
handler: (job?: JobOf<any>) => Promise<JobStatus>; handler: (job: JobOf<any>) => Promise<JobStatus>;
label: string; label: string;
}; };
@@ -95,17 +95,14 @@ export class JobRepository {
} }
} }
async run(job: JobItem) { async run({ name, data }: JobItem) {
const item = this.handlers[job.name]; const item = this.handlers[name as JobName];
if (!item) { if (!item) {
this.logger.warn(`Skipping unknown job: "${job.name}"`); this.logger.warn(`Skipping unknown job: "${name}"`);
return JobStatus.Skipped; return JobStatus.Skipped;
} }
if ('data' in job) { return item.handler(data);
return item.handler(job.data);
}
return item.handler();
} }
setConcurrency(queueName: QueueName, concurrency: number) { setConcurrency(queueName: QueueName, concurrency: number) {
@@ -170,7 +167,7 @@ export class JobRepository {
const queueName = this.getQueueName(item.name); const queueName = this.getQueueName(item.name);
const job = { const job = {
name: item.name, name: item.name,
data: ('data' in item ? item.data : undefined) || {}, data: item.data || {},
options: this.getJobOptions(item) || undefined, options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined }; } as JobItem & { data: any; options: JobsOptions | undefined };
+1 -1
View File
@@ -73,7 +73,7 @@ export class MemoryRepository implements IBulkAsset {
eb.exists( eb.exists(
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') .innerJoin('person', 'person.id', 'asset_face.personId')
.select((eb) => eb.val(1).as('one')) .select((eb) => eb.val(1).as('one'))
.whereRef('asset_face.assetId', '=', 'asset.id') .whereRef('asset_face.assetId', '=', 'asset.id')
.where('person.isHidden', '=', true), .where('person.isHidden', '=', true),
+29 -110
View File
@@ -4,8 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database'; import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SharingPermission, SourceType } from 'src/enum'; import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { hasAssetPermissions, hasAssetPermissionsRef } from 'src/repositories/asset.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table';
@@ -34,9 +33,9 @@ export interface AssetFaceId {
} }
export interface UpdateFacesData { export interface UpdateFacesData {
oldFaceClusterId?: string; oldPersonId?: string;
faceIds?: string[]; faceIds?: string[];
newFaceClusterId: string; newPersonId: string;
} }
export interface PersonStatistics { export interface PersonStatistics {
@@ -55,7 +54,7 @@ export interface GetAllPeopleOptions {
} }
export interface GetAllFacesOptions { export interface GetAllFacesOptions {
faceClusterId?: string | null; personId?: string | null;
assetId?: string; assetId?: string;
sourceType?: SourceType; sourceType?: SourceType;
} }
@@ -64,27 +63,9 @@ export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[]; export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>, userId?: string) => { const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom( return jsonObjectFrom(
eb eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
.selectFrom('person')
.selectAll('person')
.whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId')
.$if(!!userId, (qb) =>
qb.where((eb) =>
eb.or([eb('person.ownerId', '=', userId!), hasPermissions(userId!, [SharingPermission.PersonRead])(eb)]),
),
)
.orderBy(
(eb) =>
eb(
'person.ownerId',
'=',
eb.selectFrom('asset').select('asset.ownerId').whereRef('asset.id', '=', 'asset_face.assetId'),
),
'desc',
)
.limit(1),
).as('person'); ).as('person');
}; };
@@ -94,47 +75,16 @@ const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
).as('faceSearch'); ).as('faceSearch');
}; };
export const hasPermissions =
(userId: string, permissions: SharingPermission[]) => (eb: ExpressionBuilder<DB, 'person'>) =>
eb.or([
eb.exists((eb) =>
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'person.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
),
),
eb.exists((eb) =>
eb
.selectFrom('album_user')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.whereRef('album_user.userId', '=', 'person.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
]);
@Injectable() @Injectable()
export class PersonRepository { export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise<number> { async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.db const result = await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ faceClusterId: newFaceClusterId }) .set({ personId: newPersonId })
.$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!)) .$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!)) .$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.executeTakeFirst(); .executeTakeFirst();
@@ -144,7 +94,7 @@ export class PersonRepository {
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ faceClusterId: null }) .set({ personId: null })
.where('asset_face.sourceType', '=', sourceType) .where('asset_face.sourceType', '=', sourceType)
.execute(); .execute();
} }
@@ -167,8 +117,8 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null)) .$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null))
.$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!)) .$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
@@ -203,20 +153,16 @@ export class PersonRepository {
const items = await this.db const items = await this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId') .innerJoin('asset_face', 'asset_face.personId', 'person.id')
.innerJoin('asset', (join) => .innerJoin('asset', (join) =>
join join
.onRef('asset_face.assetId', '=', 'asset.id') .onRef('asset_face.assetId', '=', 'asset.id')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null), .on('asset.deletedAt', 'is', null),
) )
.where((eb) => .where('person.ownerId', '=', userId)
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.orderBy('person.faceClusterId')
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.orderBy('person.isHidden', 'asc') .orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc') .orderBy('person.isFavorite', 'desc')
.having((eb) => .having((eb) =>
@@ -225,7 +171,6 @@ export class PersonRepository {
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1), eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1),
]), ]),
) )
.distinctOn('person.faceClusterId')
.groupBy('person.id') .groupBy('person.id')
.$if(!!options?.closestFaceAssetId, (qb) => .$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) => qb.orderBy((eb) =>
@@ -264,7 +209,7 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId') .leftJoin('asset_face', 'asset_face.personId', 'person.id')
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
@@ -273,13 +218,13 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string, options: { isVisible?: boolean; userId?: string } = {}) { getFaces(assetId: string, options?: { isVisible?: boolean }) {
const { isVisible = true, userId } = options; const isVisible = options === undefined ? true : options.isVisible;
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.select((eb) => withPerson(eb, userId)) .select(withPerson)
.where('asset_face.assetId', '=', assetId) .where('asset_face.assetId', '=', assetId)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
@@ -303,7 +248,7 @@ export class PersonRepository {
getFaceForFacialRecognitionJob(id: string) { getFaceForFacialRecognitionJob(id: string) {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType']) .select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType'])
.select((eb) => .select((eb) =>
jsonObjectFrom( jsonObjectFrom(
eb eb
@@ -344,10 +289,10 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise<number> { async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.db const result = await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ faceClusterId: newFaceClusterId }) .set({ personId: newPersonId })
.where('asset_face.id', '=', assetFaceId) .where('asset_face.id', '=', assetFaceId)
.executeTakeFirst(); .executeTakeFirst();
@@ -373,7 +318,6 @@ export class PersonRepository {
.where('person.ownerId', '=', userId) .where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`) .where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`) .orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.limit(100) .limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute(); .execute();
@@ -391,7 +335,7 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(userId: string, personId: string): Promise<PersonStatistics> { async getStatistics(personId: string): Promise<PersonStatistics> {
const result = await this.db const result = await this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.leftJoin('asset', (join) => .leftJoin('asset', (join) =>
@@ -400,13 +344,10 @@ export class PersonRepository {
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null), .on('asset.deletedAt', 'is', null),
) )
.where(hasAssetPermissions(userId, [SharingPermission.AssetRead], true))
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.where('asset_face.faceClusterId', '=', (eb) => .where('asset_face.personId', '=', personId)
eb.selectFrom('person').select('person.faceClusterId').where('person.id', '=', personId),
)
.executeTakeFirst(); .executeTakeFirst();
return { return {
@@ -423,7 +364,7 @@ export class PersonRepository {
eb.exists((eb) => eb.exists((eb) =>
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId') .whereRef('asset_face.personId', '=', 'person.id')
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true) .where('asset_face.isVisible', '=', true)
.where((eb) => .where((eb) =>
@@ -437,20 +378,13 @@ export class PersonRepository {
), ),
), ),
) )
.where((eb) => .where('person.ownerId', '=', userId)
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total')) .select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden')) .select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden'))
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
async create(person: Insertable<PersonTable>) { create(person: Insertable<PersonTable>) {
if (!person.faceClusterId) {
const { id } = await this.db.insertInto('face_cluster').defaultValues().returning('id').executeTakeFirstOrThrow();
person.faceClusterId = id;
}
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
} }
@@ -541,9 +475,8 @@ export class PersonRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.select(withPerson) .select(withPerson)
.innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId'))
.where('person.id', 'in', personIds)
.where('asset_face.assetId', 'in', assetIds) .where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.execute(); .execute();
} }
@@ -553,15 +486,7 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.innerJoin('person', (join) => .where('asset_face.personId', '=', personId)
join.onRef('asset_face.faceClusterId', '=', 'person.faceClusterId').on('person.id', '=', personId),
)
.where('asset_face.assetId', 'in', (eb) =>
eb
.selectFrom('asset')
.select('asset.id')
.where((eb) => hasAssetPermissionsRef(eb, 'person.ownerId', [SharingPermission.AssetRead], true)),
)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.executeTakeFirst(); .executeTakeFirst();
@@ -648,14 +573,8 @@ export class PersonRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.select('asset_face.id') .select('asset_face.id')
.where('asset_face.assetId', '=', assetId) .where('asset_face.assetId', '=', assetId)
.innerJoin('person', (join) => .where('asset_face.personId', '=', personId)
join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId),
)
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
.executeTakeFirst(); .executeTakeFirst();
} }
getByFaceClusterId(faceClusterId: string) {
return this.db.selectFrom('person').selectAll().where('person.faceClusterId', '=', faceClusterId).execute();
}
} }
+4 -12
View File
@@ -325,23 +325,15 @@ export class SearchRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.select([ .select([
'asset_face.id', 'asset_face.id',
'asset_face.faceClusterId', 'asset_face.personId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'), sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
]) ])
.innerJoin('asset', 'asset.id', 'asset_face.assetId') .innerJoin('asset', 'asset.id', 'asset_face.assetId')
.select('asset.ownerId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id') .innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') .leftJoin('person', 'person.id', 'asset_face.personId')
.where('asset.ownerId', 'in', (eb) => .where('asset.ownerId', '=', anyUuid(userIds))
eb
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', 'in', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', anyUuid(userIds)),
),
)
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null)) .$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
.$if(!!minBirthDate, (qb) => .$if(!!minBirthDate, (qb) =>
qb.where((eb) => qb.where((eb) =>
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
+1 -1
View File
@@ -443,7 +443,7 @@ class AssetFaceSync extends BaseSync {
.select([ .select([
'asset_face.id', 'asset_face.id',
'assetId', 'assetId',
'faceClusterId', 'personId',
'imageWidth', 'imageWidth',
'imageHeight', 'imageHeight',
'boundingBoxX1', 'boundingBoxX1',
@@ -325,61 +325,4 @@ export class UserRepository {
await query.execute(); await query.execute();
} }
@GenerateSql({ params: [DummyValue.UUID] })
async getInSameTrustedGroup(userId: string) {
return this.db
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.execute()
.then((result) => result.map(({ id }) => id));
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, userIdToMerge: DummyValue.UUID }] })
async mergeTrustedGroups({ userId, userIdToMerge }: { userId: string; userIdToMerge: string }) {
return this.db
.updateTable('user')
.from('user as u')
.where('u.id', '=', userId)
.where('user.trustedGroupId', '=', (eb) =>
eb
.selectFrom('user')
.select('user.trustedGroupId')
.where('user.id', '=', userIdToMerge)
.whereRef('user.trustedGroupId', '!=', 'u.trustedGroupId'),
)
.set((eb) => ({
trustedGroupId: eb.ref('u.trustedGroupId'),
}))
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async updateTrustedGroups(userId: string) {
return this.db
.updateTable('user')
.set((eb) => ({ trustedGroupId: eb.fn('uuid_generate_v4') }))
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.where('user.id', '!=', userId)
.where('user.id', 'not in', (eb) =>
eb
.selectFrom('partner')
.select('partner.sharedById as userId')
.where('sharedWithId', '=', userId)
.union((eb) =>
eb
.selectFrom('album_user')
.select('album_user.userId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
),
),
)
.executeTakeFirst();
}
} }
-6
View File
@@ -4,7 +4,6 @@ import {
AssetStatus, AssetStatus,
AssetVisibility, AssetVisibility,
ChecksumAlgorithm, ChecksumAlgorithm,
SharingPermission,
SourceType, SourceType,
VideoSegmentCodec, VideoSegmentCodec,
} from 'src/enum'; } from 'src/enum';
@@ -38,8 +37,3 @@ export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum', name: 'video_stream_variant_codec_enum',
values: Object.values(VideoSegmentCodec), values: Object.values(VideoSegmentCodec),
}); });
export const sharing_permission_enum = registerEnum({
name: 'sharing_permission_enum',
values: Object.values(SharingPermission),
});
+1 -11
View File
@@ -4,7 +4,6 @@ import {
asset_face_source_type, asset_face_source_type,
asset_visibility_enum, asset_visibility_enum,
assets_status_enum, assets_status_enum,
sharing_permission_enum,
} from 'src/schema/enums'; } from 'src/schema/enums';
import { import {
album_user_after_insert, album_user_after_insert,
@@ -46,7 +45,6 @@ import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table'; import { LibraryTable } from 'src/schema/tables/library.table';
@@ -112,7 +110,6 @@ export class ImmichDatabase {
AssetTable, AssetTable,
AssetFileTable, AssetFileTable,
AssetExifTable, AssetExifTable,
FaceClusterTable,
FaceSearchTable, FaceSearchTable,
GeodataPlacesTable, GeodataPlacesTable,
LibraryTable, LibraryTable,
@@ -173,13 +170,7 @@ export class ImmichDatabase {
asset_face_audit, asset_face_audit,
]; ];
enum = [ enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
album_user_role_enum,
assets_status_enum,
asset_face_source_type,
asset_visibility_enum,
sharing_permission_enum,
];
} }
export interface Migrations { export interface Migrations {
@@ -220,7 +211,6 @@ export interface DB {
ocr_search: OcrSearchTable; ocr_search: OcrSearchTable;
face_search: FaceSearchTable; face_search: FaceSearchTable;
face_cluster: FaceClusterTable;
geodata_places: GeodataPlacesTable; geodata_places: GeodataPlacesTable;
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
}
@@ -1,17 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "sharing_permission_enum" AS ENUM ('all','asset.read','asset.update','asset.edit','asset.delete','asset.share','exif.read','person.read','person.update','person.merge','person.delete');`.execute(db);
await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
await sql`ALTER TABLE "album_user" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{asset.read,exif.read}';`.execute(db);
await sql`ALTER TABLE "album_user" ADD "inTimeline" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`ALTER TABLE "partner" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{all}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TYPE "sharing_permission_enum";`.execute(db);
await sql`ALTER TABLE "partner" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "inTimeline";`.execute(db);
}
@@ -1,51 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "faceClusterId";`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_idx" ON "asset_face" ("faceClusterId", "assetId");`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("faceClusterId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_faceClusterId_idx" ON "asset_face" ("assetId", "faceClusterId");`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db);
await sql`CREATE TABLE "face_cluster" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "face_cluster_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db);
await sql`ALTER TABLE "person" ADD "faceClusterId" uuid;`.execute(db);
await sql`CREATE INDEX "person_faceClusterId_idx" ON "person" ("faceClusterId");`.execute(db);
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "face_cluster_updateId_idx" ON "face_cluster" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt"
BEFORE UPDATE ON "face_cluster"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_face_cluster_updatedAt', '{"type":"trigger","name":"face_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"face_cluster\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"faceClusterId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "person" DROP COLUMN "faceClusterId";`.execute(db);
await sql`DROP INDEX "person_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceClusterId_fkey";`.execute(db);
await sql`ALTER TABLE "asset_face" RENAME COLUMN "faceClusterId" TO "personId";`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ((("deletedAt" IS NULL) AND ("isVisible" IS TRUE)));`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_faceClusterId_fkey";`.execute(db);
await sql`DROP TABLE "face_cluster";`.execute(db);
await sql`DROP TRIGGER "face_cluster_updatedAt" ON "face_cluster";`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
+2 -12
View File
@@ -11,8 +11,8 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from '@immich/sql-tools'; } from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole, SharingPermission } from 'src/enum'; import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum, sharing_permission_enum } from 'src/schema/enums'; import { album_user_role_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table'; import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@@ -69,14 +69,4 @@ export class AlbumUserTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Generated<Timestamp>; updatedAt!: Generated<Timestamp>;
@Column({
array: true,
enum: sharing_permission_enum,
default: [SharingPermission.AssetRead, SharingPermission.ExifRead],
})
permissions!: Generated<SharingPermission[]>;
@Column({ type: 'boolean', default: false })
inTimeline!: Generated<boolean>;
} }
+8 -8
View File
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums'; import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions'; import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { PersonTable } from 'src/schema/tables/person.table';
@Table({ name: 'asset_face' }) @Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt') @UpdatedAtTrigger('asset_face_updatedAt')
@@ -26,13 +26,13 @@ import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
when: 'pg_trigger_depth() = 0', when: 'pg_trigger_depth() = 0',
}) })
// schemaFromDatabase does not preserve column order // schemaFromDatabase does not preserve column order
@Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] }) @Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] })
@Index({ @Index({
name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
columns: ['faceClusterId', 'assetId'], columns: ['personId', 'assetId'],
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
}) })
@Index({ columns: ['faceClusterId', 'assetId'] }) @Index({ columns: ['personId', 'assetId'] })
export class AssetFaceTable { export class AssetFaceTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: Generated<string>; id!: Generated<string>;
@@ -45,14 +45,14 @@ export class AssetFaceTable {
}) })
assetId!: string; assetId!: string;
@ForeignKeyColumn(() => FaceClusterTable, { @ForeignKeyColumn(() => PersonTable, {
onDelete: 'SET NULL', onDelete: 'SET NULL',
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
nullable: true, nullable: true,
// [faceClusterId, assetId] makes this redundant // [personId, assetId] makes this redundant
index: false, index: false,
}) })
faceClusterId!: string | null; personId!: string | null;
@Column({ default: 0, type: 'integer' }) @Column({ default: 0, type: 'integer' })
imageWidth!: Generated<number>; imageWidth!: Generated<number>;
@@ -1,25 +0,0 @@
import {
CreateDateColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
@Table('face_cluster')
@UpdatedAtTrigger('face_cluster_updatedAt')
export class FaceClusterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
@@ -9,8 +9,6 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from '@immich/sql-tools'; } from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SharingPermission } from 'src/enum';
import { sharing_permission_enum } from 'src/schema/enums';
import { partner_delete_audit } from 'src/schema/functions'; import { partner_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@@ -48,7 +46,4 @@ export class PartnerTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@Column({ array: true, enum: sharing_permission_enum, default: [SharingPermission.All] })
permissions!: Generated<SharingPermission[]>;
} }
+3 -7
View File
@@ -14,7 +14,6 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions'; import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@Table('person') @Table('person')
@@ -44,6 +43,9 @@ export class PersonTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string; ownerId!: string;
@Column({ default: '' })
name!: Generated<string>;
@Column({ default: '' }) @Column({ default: '' })
thumbnailPath!: Generated<string>; thumbnailPath!: Generated<string>;
@@ -53,9 +55,6 @@ export class PersonTable {
@Column({ type: 'date', nullable: true }) @Column({ type: 'date', nullable: true })
birthDate!: Timestamp | null; birthDate!: Timestamp | null;
@Column({ default: '' })
name!: Generated<string>;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null; faceAssetId!: string | null;
@@ -67,7 +66,4 @@ export class PersonTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
faceClusterId!: string | null;
} }
-4
View File
@@ -4,7 +4,6 @@ import {
CreateDateColumn, CreateDateColumn,
DeleteDateColumn, DeleteDateColumn,
Generated, Generated,
GeneratedColumn,
Index, Index,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
@@ -83,7 +82,4 @@ export class UserTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@GeneratedColumn('uuid')
trustedGroupId!: Generated<string>;
} }
+2 -32
View File
@@ -8,15 +8,13 @@ import {
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
mapAlbum, mapAlbum,
SharingPermissionsResponseDto,
UpdateAlbumDto, UpdateAlbumDto,
UpdateAlbumUserDto, UpdateAlbumUserDto,
UpdateSharingPermissionsDto,
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto'; import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum'; import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -139,11 +137,6 @@ export class AlbumService extends BaseService {
); );
for (const { userId } of albumUsers) { for (const { userId } of albumUsers) {
await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: userId,
});
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name }); await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
} }
@@ -313,17 +306,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid user'); throw new BadRequestException('Invalid user');
} }
await this.userRepository.mergeTrustedGroups({ await this.albumUserRepository.create({ userId, albumId: id, role });
userId: auth.user.id,
userIdToMerge: userId,
});
await this.albumUserRepository.create({
userId,
albumId: id,
role,
permissions: [SharingPermission.AssetRead, SharingPermission.ExifRead],
});
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
} }
@@ -362,19 +345,6 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
} }
async updateSelf(auth: AuthDto, albumId: string, dto: UpdateSharingPermissionsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
await this.albumUserRepository.update(
{ albumId, userId: auth.user.id },
{ permissions: dto.permissions, inTimeline: dto.inTimeline },
);
}
async getSelf(auth: AuthDto, albumId: string): Promise<SharingPermissionsResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
return this.albumUserRepository.get({ userId: auth.user.id, albumId });
}
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) { private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options, authUserId); const album = await this.albumRepository.getById(id, options, authUserId);
if (!album) { if (!album) {
+10 -15
View File
@@ -32,11 +32,10 @@ import {
JobStatus, JobStatus,
Permission, Permission,
QueueName, QueueName,
SharingPermission,
} from 'src/enum'; } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { hasPermissions, requireElevatedPermission } from 'src/utils/access'; import { requireElevatedPermission } from 'src/utils/access';
import { import {
getAssetFiles, getAssetFiles,
getDimensions, getDimensions,
@@ -63,18 +62,14 @@ export class AssetService extends BaseService {
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> { async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const asset = await this.assetRepository.getById( const asset = await this.assetRepository.getById(id, {
id, exifInfo: true,
{ owner: true,
exifInfo: true, faces: { person: true },
owner: true, stack: { assets: true },
faces: { person: true }, edits: true,
stack: { assets: true }, tags: true,
edits: true, });
tags: true,
},
auth.user.id,
);
if (!asset) { if (!asset) {
throw new BadRequestException('Asset not found'); throw new BadRequestException('Asset not found');
@@ -90,7 +85,7 @@ export class AssetService extends BaseService {
delete data.owner; delete data.owner;
} }
if (!hasPermissions(data, SharingPermission.PersonRead)) { if (data.ownerId !== auth.user.id || auth.sharedLink) {
data.people = []; data.people = [];
} }
+1 -5
View File
@@ -85,11 +85,7 @@ export class NotificationService extends BaseService {
return; return;
} }
this.logger.error( this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data));
`Unable to run job handler (${job.name}): ${error}`,
error?.stack,
'data' in job ? JSON.stringify(job.data) : {},
);
switch (job.name) { switch (job.name) {
case JobName.DatabaseBackup: { case JobName.DatabaseBackup: {
+2 -14
View File
@@ -3,7 +3,7 @@ import { Partner } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto'; import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto'; import { mapUser } from 'src/dtos/user.dto';
import { JobName, Permission, SharingPermission } from 'src/enum'; import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository'; import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@@ -16,15 +16,7 @@ export class PartnerService extends BaseService {
throw new BadRequestException(`Partner already exists`); throw new BadRequestException(`Partner already exists`);
} }
const { numUpdatedRows } = await this.userRepository.mergeTrustedGroups({ const partner = await this.partnerRepository.create(partnerId);
userId: auth.user.id,
userIdToMerge: sharedWithId,
});
const partner = await this.partnerRepository.create({ ...partnerId, permissions: [SharingPermission.All] });
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionMerge, data: { id: sharedWithId } });
}
return this.mapPartner(partner, PartnerDirection.SharedBy); return this.mapPartner(partner, PartnerDirection.SharedBy);
} }
@@ -36,10 +28,6 @@ export class PartnerService extends BaseService {
} }
await this.partnerRepository.remove(partnerId); await this.partnerRepository.remove(partnerId);
const { numUpdatedRows } = await this.userRepository.updateTrustedGroups(auth.user.id);
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force: true } });
}
} }
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> { async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
+18 -111
View File
@@ -13,7 +13,7 @@ import {
FaceDto, FaceDto,
mapFaces, mapFaces,
mapPerson, mapPerson,
MergeFaceClusterDto, MergePersonDto,
PeopleResponseDto, PeopleResponseDto,
PeopleUpdateDto, PeopleUpdateDto,
PersonCreateDto, PersonCreateDto,
@@ -127,11 +127,11 @@ export class PersonService extends BaseService {
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> { async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id, { userId: auth.user.id }); const faces = await this.personRepository.getFaces(dto.id);
const asset = await this.assetRepository.getForFaces(dto.id); const asset = await this.assetRepository.getForFaces(dto.id);
const assetDimensions = getDimensions(asset); const assetDimensions = getDimensions(asset);
return faces.map((face) => mapFaces(face, asset.edits, assetDimensions)); return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
} }
async createNewFeaturePhoto(changeFeaturePhoto: string[]) { async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
@@ -159,7 +159,7 @@ export class PersonService extends BaseService {
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> { async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return this.personRepository.getStatistics(auth.user.id, id); return this.personRepository.getStatistics(id);
} }
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> { async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
@@ -438,7 +438,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString(); const lastRun = new Date().toISOString();
const facePagination = this.personRepository.getAllFaces( const facePagination = this.personRepository.getAllFaces(
force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning }, force ? undefined : { personId: null, sourceType: SourceType.MachineLearning },
); );
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = []; let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
@@ -481,8 +481,8 @@ export class PersonService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
if (face.faceClusterId) { if (face.personId) {
this.logger.debug(`Face ${id} already belongs to a face cluster`); this.logger.debug(`Face ${id} already has a person assigned`);
return JobStatus.Skipped; return JobStatus.Skipped;
} }
@@ -511,8 +511,8 @@ export class PersonService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId; let personId = matches.find((match) => match.personId)?.personId;
if (!faceClusterId) { if (!personId) {
const matchWithPerson = await this.searchRepository.searchFaces({ const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId], userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding, embedding: face.faceSearch.embedding,
@@ -523,108 +523,20 @@ export class PersonService extends BaseService {
}); });
if (matchWithPerson.length > 0) { if (matchWithPerson.length > 0) {
faceClusterId = matchWithPerson[0].faceClusterId; personId = matchWithPerson[0].personId;
} }
} }
if (isCore && !faceClusterId) { if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`); this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
faceClusterId = newPerson.faceClusterId; personId = newPerson.id;
} }
if (faceClusterId) { if (personId) {
this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`); this.logger.debug(`Assigning face ${id} to person ${personId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId }); await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
}
return JobStatus.Success;
}
@OnJob({ name: JobName.FacialRecognitionMerge, queue: QueueName.FacialRecognition })
async mergeClusters({ id: userId }: JobOf<JobName.FacialRecognitionMerge>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.Skipped;
}
const faces = this.personRepository.getAllFaces({ sourceType: SourceType.MachineLearning });
for await (const { id } of faces) {
const face = await this.personRepository.getFaceForFacialRecognitionJob(id);
if (!face?.faceSearch || !face.asset) {
this.logger.warn(`Face ${id} does not have an embedding`);
continue;
}
if (face.asset.ownerId === userId) {
continue;
}
let faceClusterId: string | null = null;
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 100,
hasPerson: true,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
if (matchWithPerson.length > 0) {
// favor a person that's not owned by us to merge people with a newly shared with user
// probably do smarter stuff here like pick the person with a name, if both have a name set aliases or whatever
const match = matchWithPerson.find((match) => match.ownerId !== userId) ?? matchWithPerson[0];
if (match.faceClusterId && face.asset.ownerId !== match.ownerId) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const person = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
await this.createNewFeaturePhoto([person.id]);
}
}
faceClusterId = match.faceClusterId;
}
if (!faceClusterId) {
const matches = await this.searchRepository.searchFaces({
userIds: [userId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
const match = matches.find((match) => match.faceClusterId);
if (
match &&
match.faceClusterId &&
face.asset.ownerId !== match.ownerId &&
matches.length >= machineLearning.facialRecognition.minFaces
) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const person = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
await this.createNewFeaturePhoto([person.id]);
}
}
faceClusterId = match?.faceClusterId ?? null;
}
if (faceClusterId) {
this.logger.log(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
}
} }
return JobStatus.Success; return JobStatus.Success;
@@ -642,7 +554,7 @@ export class PersonService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise<BulkIdResponseDto[]> { async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids; const mergeIds = dto.ids;
if (mergeIds.includes(id)) { if (mergeIds.includes(id)) {
throw new BadRequestException('Cannot merge a person into themselves'); throw new BadRequestException('Cannot merge a person into themselves');
@@ -688,7 +600,7 @@ export class PersonService extends BaseService {
} }
const mergeName = mergePerson.name || mergePerson.id; const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id }; const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`); this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData); await this.personRepository.reassignFaces(mergeData);
@@ -701,7 +613,6 @@ export class PersonService extends BaseService {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN }); results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
} }
} }
return results; return results;
} }
@@ -771,12 +682,8 @@ export class PersonService extends BaseService {
dto.imageHeight = originalDimensions.height; dto.imageHeight = originalDimensions.height;
} }
if (!person?.faceClusterId) {
throw new Error('Person must already have some recognized faces and belong to a face cluster');
}
await this.personRepository.createAssetFace({ await this.personRepository.createAssetFace({
faceClusterId: person.faceClusterId, personId: dto.personId,
assetId: dto.assetId, assetId: dto.assetId,
imageHeight: dto.imageHeight, imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth, imageWidth: dto.imageWidth,
-1
View File
@@ -208,7 +208,6 @@ export class SearchService extends BaseService {
repository: this.partnerRepository, repository: this.partnerRepository,
timelineEnabled: true, timelineEnabled: true,
}); });
console.log(auth.user.id, partnerIds);
return [auth.user.id, ...partnerIds]; return [auth.user.id, ...partnerIds];
} }
+1 -4
View File
@@ -204,9 +204,7 @@ export type ConcurrentQueueName = Exclude<
| QueueName.BackupDatabase | QueueName.BackupDatabase
>; >;
export type Jobs = { export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] };
[K in JobItem['name']]: 'data' extends keyof (JobItem & { name: K }) ? (JobItem & { name: K })['data'] : never;
};
export type JobOf<T extends JobName> = Jobs[T]; export type JobOf<T extends JobName> = Jobs[T];
export interface IBaseJob { export interface IBaseJob {
@@ -353,7 +351,6 @@ export type JobItem =
| { name: JobName.AssetDetectFaces; data: IEntityJob } | { name: JobName.AssetDetectFaces; data: IEntityJob }
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob } | { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
| { name: JobName.FacialRecognition; data: IDeferrableJob } | { name: JobName.FacialRecognition; data: IDeferrableJob }
| { name: JobName.FacialRecognitionMerge; data: IEntityJob }
| { name: JobName.PersonGenerateThumbnail; data: IEntityJob } | { name: JobName.PersonGenerateThumbnail; data: IEntityJob }
// Smart Search // Smart Search
+22 -82
View File
@@ -1,7 +1,7 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthSharedLink } from 'src/database'; import { AuthSharedLink } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum'; import { AlbumUserRole, Permission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set';
@@ -115,41 +115,37 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
case Permission.AssetRead: { case Permission.AssetRead: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isShared); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
} }
case Permission.AssetShare: { case Permission.AssetShare: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false); const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetShare]); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isShared); return setUnion(isOwner, isPartner);
} }
case Permission.AssetView: { case Permission.AssetView: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isShared); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
} }
case Permission.AssetDownload: { case Permission.AssetDownload: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [ const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
SharingPermission.AssetRead, const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
SharingPermission.ExifRead, return setUnion(isOwner, isAlbum, isPartner);
]);
return setUnion(isOwner, isShared);
} }
case Permission.AssetUpdate: { case Permission.AssetUpdate: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetUpdate]);
return setUnion(isOwner, isShared);
} }
case Permission.AssetDelete: { case Permission.AssetDelete: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetDelete]);
return setUnion(isOwner, isShared);
} }
case Permission.AssetCopy: { case Permission.AssetCopy: {
@@ -157,21 +153,15 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
} }
case Permission.AssetEditGet: { case Permission.AssetEditGet: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
} }
case Permission.AssetEditCreate: { case Permission.AssetEditCreate: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
} }
case Permission.AssetEditDelete: { case Permission.AssetEditDelete: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
} }
case Permission.AlbumRead: { case Permission.AlbumRead: {
@@ -256,11 +246,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
} }
case Permission.FaceDelete: { case Permission.FaceDelete: {
const isOwner = await access.person.checkFaceOwnerAccess(auth.user.id, ids); return access.person.checkFaceOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedFaceAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.AssetUpdate,
]);
return setUnion(isOwner, isShared);
} }
case Permission.NotificationRead: case Permission.NotificationRead:
@@ -302,40 +288,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.person.checkFaceOwnerAccess(auth.user.id, ids); return access.person.checkFaceOwnerAccess(auth.user.id, ids);
} }
case Permission.PersonRead: { case Permission.PersonRead:
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids); case Permission.PersonUpdate:
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [ case Permission.PersonDelete:
SharingPermission.PersonRead,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonMerge: { case Permission.PersonMerge: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids); return await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonMerge,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonUpdate: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonUpdate,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonDelete: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonDelete,
]);
return setUnion(isOwner, isShared);
} }
case Permission.PersonReassign: { case Permission.PersonReassign: {
@@ -382,20 +339,3 @@ export const requireElevatedPermission = (auth: AuthDto) => {
throw new UnauthorizedException('Elevated permission is required'); throw new UnauthorizedException('Elevated permission is required');
} }
}; };
export const hasPermissions = (
assetLike: { permissions: SharingPermission[] },
...permissions: SharingPermission[]
) => {
if (assetLike.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!assetLike.permissions.includes(permission)) {
return false;
}
}
return true;
};
+1 -6
View File
@@ -4,7 +4,7 @@ import { AssetFile } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetType, AssetVisibility, Permission, SharingPermission } from 'src/enum'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
@@ -134,11 +134,6 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
continue; continue;
} }
const permissions = [SharingPermission.All, SharingPermission.AssetRead];
if (!permissions.some((permission) => partner.permissions.includes(permission))) {
continue;
}
partnerIds.add(partner.sharedById); partnerIds.add(partner.sharedById);
} }
+12 -48
View File
@@ -15,17 +15,9 @@ import {
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres'; import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty } from 'src/database'; import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
AssetFileType,
AssetOrderBy,
AssetVisibility,
DatabaseExtension,
ExifOrientation,
SharingPermission,
} from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -220,22 +212,19 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
export function withFacesAndPeople( export function withFacesAndPeople(
eb: ExpressionBuilder<DB, 'asset'>, eb: ExpressionBuilder<DB, 'asset'>,
{ withHidden, withDeletedFace, userId: _ }: { withHidden?: boolean; withDeletedFace?: boolean; userId?: string } = {}, withHidden?: boolean,
withDeletedFace?: boolean,
) { ) {
return jsonArrayFrom( return jsonArrayFrom(
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.select((eb) => .leftJoinLateral(
jsonObjectFrom( (eb) =>
eb eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
.selectFrom('face_cluster') (join) => join.onTrue(),
.whereRef('face_cluster.id', '=', 'asset_face.faceClusterId')
.innerJoin('person', 'person.faceClusterId', 'face_cluster.id')
.selectAll('person')
.limit(1),
).as('person'),
) )
.selectAll('asset_face') .selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
.whereRef('asset_face.assetId', '=', 'asset.id') .whereRef('asset_face.assetId', '=', 'asset.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
@@ -248,12 +237,11 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds:
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.select('assetId') .select('assetId')
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') .where('personId', '=', anyUuid(personIds!))
.where('person.id', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('isVisible', 'is', true) .where('isVisible', 'is', true)
.groupBy('assetId') .groupBy('assetId')
.having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length) .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.as('has_people'), .as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'asset.id'), (join) => join.onRef('has_people.assetId', '=', 'asset.id'),
); );
@@ -314,30 +302,6 @@ export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
} }
export function withPermissions(userId: string) {
return (eb: ExpressionBuilder<DB, 'asset'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.select((eb) => eb.fn<SharingPermission>('unnest', ['album_user.permissions']).as('permission'))
.distinct()
.innerJoin('album_asset', 'album_user.albumId', 'album_asset.albumId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.union(
eb
.selectFrom('partner')
.select((eb) => eb.fn<SharingPermission>('unnest', ['partner.permissions']).as('permission'))
.distinct()
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId),
),
).as('permissions');
}
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) { export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
return qb.where((eb) => return qb.where((eb) =>
eb.exists( eb.exists(
@@ -464,7 +428,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!)) .$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
.$if(!!options.userIds, (qb) => qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead]))) .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(!!options.encodedVideoPath, (qb) => .$if(!!options.encodedVideoPath, (qb) =>
qb qb
.innerJoin('asset_file', (join) => .innerJoin('asset_file', (join) =>
-1
View File
@@ -38,7 +38,6 @@ const createAsset = (
fileSizeInByte !== null || Object.keys(exifFields).length > 0 fileSizeInByte !== null || Object.keys(exifFields).length > 0
? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields }) ? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields })
: undefined, : undefined,
permissions: [],
}); });
describe('duplicate utils', () => { describe('duplicate utils', () => {
+4 -13
View File
@@ -1,3 +1,4 @@
import { AssetFace } from 'src/database';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageDimensions } from 'src/types'; import { ImageDimensions } from 'src/types';
@@ -30,21 +31,11 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
}; };
}; };
export const checkFaceVisibility = < export const checkFaceVisibility = (
T extends { faces: AssetFace[],
isVisible: boolean;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
},
>(
faces: T[],
originalAssetDimensions: ImageDimensions, originalAssetDimensions: ImageDimensions,
crop?: BoundingBox, crop?: BoundingBox,
): { visible: T[]; hidden: T[] } => { ): { visible: AssetFace[]; hidden: AssetFace[] } => {
if (!crop) { if (!crop) {
return { return {
visible: faces.filter((face) => !face.isVisible), visible: faces.filter((face) => !face.isVisible),
@@ -28,8 +28,6 @@ export class AlbumUserFactory {
createdAt: newDate(), createdAt: newDate(),
updateId: newUuidV7(), updateId: newUuidV7(),
updatedAt: newDate(), updatedAt: newDate(),
permissions: [],
inTimeline: false,
...dto, ...dto,
}); });
} }
-1
View File
@@ -26,7 +26,6 @@ export class PartnerFactory {
sharedWithId, sharedWithId,
updatedAt: newDate(), updatedAt: newDate(),
updateId: newUuidV7(), updateId: newUuidV7(),
permissions: [],
...dto, ...dto,
}) })
.sharedBy({ id: sharedById }) .sharedBy({ id: sharedById })
-1
View File
@@ -35,7 +35,6 @@ export class UserFactory {
status: UserStatus.Active, status: UserStatus.Active,
profileChangedAt: newDate(), profileChangedAt: newDate(),
updateId: newUuidV7(), updateId: newUuidV7(),
trustedGroupId: newUuid(),
...dto, ...dto,
}); });
} }
@@ -21,7 +21,6 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()), checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()),
checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()), checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
}, },
album: { album: {
@@ -49,8 +48,6 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
person: { person: {
checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedFaceAccess: vitest.fn().mockResolvedValue(new Set()),
}, },
partner: { partner: {
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0", "@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@immich/ui": "^0.77.0", "@immich/ui": "^0.79.0",
"@mapbox/mapbox-gl-rtl-text": "0.4.0", "@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
@@ -103,7 +103,7 @@
{/if} {/if}
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" /> <Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -26,13 +26,12 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service'; import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, hasPermissions, withoutIcons } from '$lib/utils'; import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions'; import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
AssetTypeEnum, AssetTypeEnum,
AssetVisibility, AssetVisibility,
SharingPermission,
type AlbumResponseDto, type AlbumResponseDto,
type AssetResponseDto, type AssetResponseDto,
type PersonResponseDto, type PersonResponseDto,
@@ -142,7 +141,7 @@
<ActionButton action={Actions.Edit} /> <ActionButton action={Actions.Edit} />
{#if hasPermissions(asset, SharingPermission.AssetDelete)} {#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} /> <DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if} {/if}
@@ -160,7 +159,7 @@
{/if} {/if}
<ActionMenuItem action={Actions.AddToAlbum} /> <ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (hasPermissions(asset, SharingPermission.AssetShare) || isAlbumOwner)} {#if album && (isOwner || isAlbumOwner)}
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem /> <RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if} {/if}
@@ -188,7 +187,7 @@
{/if} {/if}
{#if !isLocked} {#if !isLocked}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)} {#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} /> <ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed} {#if !asset.isArchived && !asset.isTrashed}
<MenuOption <MenuOption
@@ -218,7 +217,7 @@
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')} text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/> />
{/if} {/if}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)} {#if isOwner}
<hr /> <hr />
<ActionMenuItem action={Actions.RefreshFacesJob} /> <ActionMenuItem action={Actions.RefreshFacesJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} /> <ActionMenuItem action={Actions.RefreshMetadataJob} />
@@ -3,7 +3,6 @@
import DetailPanelDate from '$lib/components/asset-viewer/DetailPanelDate.svelte'; import DetailPanelDate from '$lib/components/asset-viewer/DetailPanelDate.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/DetailPanelDescription.svelte'; import DetailPanelDescription from '$lib/components/asset-viewer/DetailPanelDescription.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/DetailPanelLocation.svelte'; import DetailPanelLocation from '$lib/components/asset-viewer/DetailPanelLocation.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/DetailPanelStarRating.svelte'; import DetailPanelRating from '$lib/components/asset-viewer/DetailPanelStarRating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/DetailPanelTags.svelte'; import DetailPanelTags from '$lib/components/asset-viewer/DetailPanelTags.svelte';
import { timeToLoadTheMap } from '$lib/constants'; import { timeToLoadTheMap } from '$lib/constants';
@@ -12,7 +11,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, hasPermissions } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils'; import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -21,7 +20,6 @@
AssetMediaSize, AssetMediaSize,
getAllAlbums, getAllAlbums,
getAssetInfo, getAssetInfo,
SharingPermission,
type AlbumResponseDto, type AlbumResponseDto,
type AssetResponseDto, type AssetResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
@@ -34,6 +32,7 @@
import OnEvents from '../OnEvents.svelte'; import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte'; import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte'; import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -43,7 +42,6 @@
let { asset, currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
const allowExifUpdate = $derived(hasPermissions(asset, SharingPermission.AssetUpdate, SharingPermission.ExifRead));
let latlng = $derived( let latlng = $derived(
(() => { (() => {
const lat = asset.exifInfo?.latitude; const lat = asset.exifInfo?.latitude;
@@ -149,9 +147,9 @@
</section> </section>
{/if} {/if}
<DetailPanelDescription {asset} {allowExifUpdate} /> <DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {allowExifUpdate} /> <DetailPanelRating {asset} {isOwner} />
<DetailPanelPeople {asset} {previousRoute} /> <DetailPanelPeople {asset} {isOwner} {previousRoute} />
<div class="p-4"> <div class="p-4">
{#if asset.exifInfo} {#if asset.exifInfo}
@@ -162,7 +160,7 @@
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text> <Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if} {/if}
<DetailPanelDate {asset} {allowExifUpdate} /> <DetailPanelDate {asset} />
<div class="flex gap-4 py-4"> <div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div> <div><Icon icon={mdiImageOutline} size="24" /></div>
@@ -170,7 +168,7 @@
<div> <div>
<p class="flex place-items-center gap-2 break-all whitespace-pre-wrap"> <p class="flex place-items-center gap-2 break-all whitespace-pre-wrap">
{asset.originalFileName} {asset.originalFileName}
{#if allowExifUpdate} {#if isOwner}
<IconButton <IconButton
icon={mdiInformationOutline} icon={mdiInformationOutline}
aria-label={$t('show_file_location')} aria-label={$t('show_file_location')}
@@ -273,7 +271,7 @@
</div> </div>
{/if} {/if}
<DetailPanelLocation {allowExifUpdate} {asset} /> <DetailPanelLocation {isOwner} {asset} />
</div> </div>
</section> </section>
@@ -10,10 +10,9 @@
type Props = { type Props = {
asset: AssetResponseDto; asset: AssetResponseDto;
allowExifUpdate: boolean;
}; };
const { asset, allowExifUpdate }: Props = $props(); const { asset }: Props = $props();
const timeZone = $derived(asset.exifInfo?.timeZone ?? undefined); const timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
const dateTime = $derived( const dateTime = $derived(
@@ -21,8 +20,13 @@
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime), : fromISODateTimeUTC(asset.localDateTime),
); );
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const handleChangeDate = async () => { const handleChangeDate = async () => {
if (!isOwner) {
return;
}
await modalManager.show(AssetChangeDateModal, { await modalManager.show(AssetChangeDateModal, {
asset: toTimelineAsset(asset), asset: toTimelineAsset(asset),
initialDate: dateTime, initialDate: dateTime,
@@ -36,8 +40,8 @@
type="button" type="button"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start" class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={handleChangeDate} onclick={handleChangeDate}
title={allowExifUpdate ? $t('edit_date') : ''} title={isOwner ? $t('edit_date') : ''}
class:hover:text-primary={allowExifUpdate} class:hover:text-primary={isOwner}
data-testid="detail-panel-edit-date-button" data-testid="detail-panel-edit-date-button"
> >
<div class="flex gap-4"> <div class="flex gap-4">
@@ -64,13 +68,13 @@
</div> </div>
</div> </div>
{#if allowExifUpdate} {#if isOwner}
<div class="p-1"> <div class="p-1">
<Icon icon={mdiPencil} size="20" /> <Icon icon={mdiPencil} size="20" />
</div> </div>
{/if} {/if}
</button> </button>
{:else if !dateTime && allowExifUpdate} {:else if !dateTime && isOwner}
<div class="flex place-items-start justify-between gap-4 py-4"> <div class="flex place-items-start justify-between gap-4 py-4">
<div class="flex gap-4"> <div class="flex gap-4">
<Icon icon={mdiCalendar} size="24" /> <Icon icon={mdiCalendar} size="24" />
@@ -8,10 +8,10 @@
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
allowExifUpdate: boolean; isOwner: boolean;
} }
let { asset, allowExifUpdate }: Props = $props(); let { asset, isOwner }: Props = $props();
let description = $derived(asset.exifInfo?.description ?? ''); let description = $derived(asset.exifInfo?.description ?? '');
@@ -29,7 +29,7 @@
}; };
</script> </script>
{#if allowExifUpdate} {#if isOwner}
<section class="mt-10 px-4"> <section class="mt-10 px-4">
<Textarea <Textarea
bind:value={description} bind:value={description}
@@ -7,11 +7,11 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
allowExifUpdate: boolean; isOwner: boolean;
asset: AssetResponseDto; asset: AssetResponseDto;
}; };
let { allowExifUpdate, asset = $bindable() }: Props = $props(); let { isOwner, asset = $bindable() }: Props = $props();
const onAction = async () => { const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset }); const point = await modalManager.show(GeolocationPointPickerModal, { asset });
@@ -34,9 +34,9 @@
<button <button
type="button" type="button"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start" class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={allowExifUpdate ? onAction : undefined} onclick={isOwner ? onAction : undefined}
title={allowExifUpdate ? $t('edit_location') : ''} title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={allowExifUpdate} class:hover:text-primary={isOwner}
> >
<div class="flex gap-4"> <div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div> <div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
@@ -58,13 +58,13 @@
</div> </div>
</div> </div>
{#if allowExifUpdate} {#if isOwner}
<div> <div>
<Icon icon={mdiPencil} size="20" /> <Icon icon={mdiPencil} size="20" />
</div> </div>
{/if} {/if}
</button> </button>
{:else if !asset.exifInfo?.city && allowExifUpdate} {:else if !asset.exifInfo?.city && isOwner}
<button <button
type="button" type="button"
class="flex w-full place-items-start justify-between gap-4 rounded-lg py-4 text-start hover:text-primary" class="flex w-full place-items-start justify-between gap-4 rounded-lg py-4 text-start hover:text-primary"
@@ -5,8 +5,8 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { faceManager } from '$lib/stores/face.svelte'; import { faceManager } from '$lib/stores/face.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl, hasPermissions } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { SharingPermission, type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui'; import { IconButton, Text } from '@immich/ui';
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js'; import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@@ -14,13 +14,13 @@
type Props = { type Props = {
asset: AssetResponseDto; asset: AssetResponseDto;
isOwner: boolean;
previousRoute: string; previousRoute: string;
}; };
const { asset, previousRoute }: Props = $props(); const { asset, isOwner, previousRoute }: Props = $props();
const people = $derived(Array.from(faceManager.people)); const people = $derived(Array.from(faceManager.people));
$effect(() => console.log(people));
const visiblePeople = $derived( const visiblePeople = $derived(
people people
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden) .filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
@@ -56,7 +56,7 @@
); );
</script> </script>
{#if !authManager.isSharedLink && hasPermissions(asset, SharingPermission.PersonRead)} {#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm"> <section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between"> <div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text> <Text size="small" color="muted">{$t('people')}</Text>
@@ -8,10 +8,10 @@
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
allowExifUpdate: boolean; isOwner: boolean;
} }
let { asset, allowExifUpdate }: Props = $props(); let { asset, isOwner }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || null) as Rating; let rating = $derived(asset.exifInfo?.rating || null) as Rating;
@@ -26,10 +26,6 @@
{#if !authManager.isSharedLink && authManager.authenticated && authManager.preferences.ratings.enabled} {#if !authManager.isSharedLink && authManager.authenticated && authManager.preferences.ratings.enabled}
<section class="px-4 pt-4"> <section class="px-4 pt-4">
<StarRating <StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
{rating}
readOnly={!allowExifUpdate}
onRating={(rating) => handlePromiseError(handleChangeRating(rating))}
/>
</section> </section>
{/if} {/if}
@@ -91,7 +91,7 @@
</div> </div>
</main> </main>
<header> <header>
<ControlAppBar showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" /> <Logo variant="inline" />
@@ -18,7 +18,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton, Logo, toastManager } from '@immich/ui'; import { IconButton, Logo, toastManager } from '@immich/ui';
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js'; import { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte'; import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
@@ -97,7 +97,7 @@
{/if} {/if}
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" /> <Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -1,97 +1,49 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
import { IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import { onDestroy, onMount, type Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import type { ClassValue } from 'svelte/elements';
import { fly } from 'svelte/transition';
interface Props { interface Props {
showBackButton?: boolean;
backIcon?: string; backIcon?: string;
tailwindClasses?: string; class?: ClassValue;
forceDark?: boolean;
multiRow?: boolean;
onClose?: () => void; onClose?: () => void;
title?: Snippet | string;
leading?: Snippet; leading?: Snippet;
children?: Snippet; children?: Snippet;
trailing?: Snippet; trailing?: Snippet;
} }
let { let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
showBackButton = true,
backIcon = mdiClose,
tailwindClasses = '',
forceDark = false,
multiRow = false,
onClose = () => {},
leading,
children,
trailing,
}: Props = $props();
let appBarBorder = $state('border border-subtle');
const onScroll = () => {
if (window.scrollY > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'border border-subtle';
}
};
onMount(() => {
if (browser) {
document.addEventListener('scroll', onScroll, { passive: true });
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', onScroll);
}
});
</script> </script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent"> <div class={['absolute top-0 w-full bg-transparent p-2']}>
<nav <ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
id="asset-selection-app-bar" {#if title || leading}
class={[ <ControlBarHeader>
'grid', {#if title}
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]', <ControlBarTitle>
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]', {#if typeof title === 'string'}
'justify-between lg:grid-cols-[25%_50%_25%]', {title}
appBarBorder, {:else}
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0', {@render title()}
tailwindClasses, {/if}
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray', </ControlBarTitle>
]} {/if}
> {@render leading?.()}
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}"> </ControlBarHeader>
{#if showBackButton} {/if}
<IconButton
aria-label={$t('close')}
onclick={onClose}
color="secondary"
shape="round"
variant="ghost"
icon={backIcon}
size="large"
/>
{/if}
{@render leading?.()}
</div>
<div class="w-full"> {#if children}
{@render children?.()} <ControlBarContent>
</div> {@render children()}
</ControlBarContent>
{/if}
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0"> {#if trailing}
{@render trailing?.()} <ControlBarOverflow>
</div> {@render trailing()}
</nav> </ControlBarOverflow>
{/if}
</ControlBar>
</div> </div>
@@ -7,19 +7,18 @@
type Props = { type Props = {
children?: Snippet; children?: Snippet;
forceDark?: boolean;
}; };
let { children, forceDark }: Props = $props(); let { children }: Props = $props();
const onClose = () => assetMultiSelectManager.clear(); const onClose = () => assetMultiSelectManager.clear();
const assets = $derived(assetMultiSelectManager.assets); const assets = $derived(assetMultiSelectManager.assets);
</script> </script>
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md"> <ControlAppBar {onClose} backIcon={mdiClose}>
{#snippet leading()} {#snippet leading()}
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}"> <div class="font-medium text-primary">
<p class="block sm:hidden">{assets.length}</p> <p class="block sm:hidden">{assets.length}</p>
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p> <p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
</div> </div>
-1
View File
@@ -16,7 +16,6 @@
{ title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup }, { title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
{ title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate }, { title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
{ title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase }, { title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase },
{ title: 'Person grouping', value: ManualJobName.PersonGroupMerge },
].map(({ value, title }) => ({ id: value, label: title, value })); ].map(({ value, title }) => ({ id: value, label: title, value }));
let selectedJob: ComboBoxOption | undefined = $state(undefined); let selectedJob: ComboBoxOption | undefined = $state(undefined);
@@ -36,7 +36,7 @@
try { try {
await mergePerson({ await mergePerson({
id: personToBeMergedInto.id, id: personToBeMergedInto.id,
mergeFaceClusterDto: { ids: [personToMerge.id] }, mergePersonDto: { ids: [personToMerge.id] },
}); });
toastManager.primary($t('merge_people_successfully')); toastManager.primary($t('merge_people_successfully'));
onClose([personToMerge, personToBeMergedInto]); onClose([personToMerge, personToBeMergedInto]);
@@ -1,78 +0,0 @@
<script lang="ts">
import { getOwnAlbumUser, SharingPermission, updateOwnAlbumUser } from '@immich/sdk';
import { Checkbox, Field, FormModal, Heading, Stack, Switch, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { init } from 'svelte-i18n';
type Props = {
onClose: () => void;
albumId?: string;
partnerId?: string;
};
const { onClose, ...rest }: Props = $props();
let checkedPermissions = $state<SharingPermission[]>([]);
let viewInTimeline = $state<boolean>(false);
const onCheckedChange = (permission: SharingPermission, checked: boolean) => {
if (checked) {
checkedPermissions.push(permission);
} else {
checkedPermissions = checkedPermissions.filter((perm) => perm !== permission);
}
};
const onSubmit = async () => {
const permissions =
checkedPermissions.length === Object.values(SharingPermission).length - 1
? [SharingPermission.All]
: checkedPermissions;
if (rest.albumId) {
await updateOwnAlbumUser({
id: rest.albumId,
updateSharingOptionsDto: { permissions, inTimeline: viewInTimeline },
});
toastManager.success();
}
onClose();
};
onMount(async () => {
if (rest.albumId) {
const { permissions, inTimeline } = await getOwnAlbumUser({ id: rest.albumId });
checkedPermissions = permissions;
viewInTimeline = inTimeline;
}
});
</script>
<FormModal title="Sharing options" {onClose} {onSubmit}>
<Stack>
<Field label="View in timeline">
<Switch bind:checked={viewInTimeline} />
</Field>
<Heading>Permissions</Heading>
<Field label={SharingPermission.All}>
<Checkbox
id="permission-{SharingPermission.All}"
checked={checkedPermissions.length === Object.values(SharingPermission).length - 1}
onCheckedChange={(checked) =>
checked
? (checkedPermissions = Object.values(SharingPermission).filter(
(permission) => permission !== SharingPermission.All,
))
: (checkedPermissions = [])}
/>
</Field>
{#each Object.values(SharingPermission).filter((permission) => permission !== SharingPermission.All) as permission (permission)}
<Field label={permission}>
<Checkbox
id="permission-{permission}"
checked={checkedPermissions.includes(permission)}
onCheckedChange={(checked) => onCheckedChange(permission, checked)}
/>
</Field>
{/each}
</Stack>
</FormModal>
+4 -10
View File
@@ -5,7 +5,6 @@ import {
AssetVisibility, AssetVisibility,
getAssetInfo, getAssetInfo,
runAssetJobs, runAssetJobs,
SharingPermission,
updateAsset, updateAsset,
type AssetJobsDto, type AssetJobsDto,
type AssetResponseDto, type AssetResponseDto,
@@ -42,7 +41,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte'; import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { getAssetMediaUrl, getSharedLink, hasPermissions, sleep } from '$lib/utils'; import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils'; import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
@@ -99,12 +98,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const Share: ActionItem = { const Share: ActionItem = {
title: $t('share'), title: $t('share'),
icon: mdiShareVariantOutline, icon: mdiShareVariantOutline,
$if: () => $if: () => !!(authUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
!!(
hasPermissions(asset, SharingPermission.AssetShare) &&
!asset.isTrashed &&
asset.visibility !== AssetVisibility.Locked
),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
}; };
@@ -125,7 +119,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const SharedLinkDownload: ActionItem = { const SharedLinkDownload: ActionItem = {
...Download, ...Download,
$if: () => hasPermissions(asset, SharingPermission.AssetShare) || !!sharedLink?.allowDownload, $if: () => isOwner || !!sharedLink?.allowDownload,
}; };
const PlayMotionPhoto: ActionItem = { const PlayMotionPhoto: ActionItem = {
@@ -228,7 +222,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
icon: mdiTune, icon: mdiTune,
$if: () => $if: () =>
!sharedLink && !sharedLink &&
hasPermissions(asset, SharingPermission.AssetEdit) && isOwner &&
asset.type === AssetTypeEnum.Image && asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId && !asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR && asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
-15
View File
@@ -2,7 +2,6 @@ import {
AssetMediaSize, AssetMediaSize,
AssetTypeEnum, AssetTypeEnum,
MemoryType, MemoryType,
SharingPermission,
finishOAuth, finishOAuth,
getAssetOriginalPath, getAssetOriginalPath,
getAssetPlaybackPath, getAssetPlaybackPath,
@@ -414,17 +413,3 @@ export const transformToTitleCase = (text: string) => {
} }
return result.trim(); return result.trim();
}; };
export const hasPermissions = (asset: AssetResponseDto, ...permissions: SharingPermission[]) => {
if (asset.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!asset.permissions.includes(permission)) {
return false;
}
}
return true;
};
@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto, invalidate, onNavigate } from '$app/navigation'; import { goto, invalidate, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import AlbumDescription from './AlbumDescription.svelte';
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte'; import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte'; import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
import AlbumTitle from './AlbumTitle.svelte';
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte'; import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte'; import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
@@ -73,13 +71,13 @@
mdiLink, mdiLink,
mdiPlus, mdiPlus,
mdiPresentationPlay, mdiPresentationPlay,
mdiShareVariant,
} from '@mdi/js'; } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import SharingOptionsModal from '$lib/modals/SharingOptionsModal.svelte'; import AlbumDescription from './AlbumDescription.svelte';
import AlbumTitle from './AlbumTitle.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -407,16 +405,9 @@
{/if} {/if}
</button> </button>
<IconButton {#if isOwned}
shape="round" <ActionButton action={Share} />
aria-label="Sharing permissions" {/if}
color="secondary"
size="medium"
icon={mdiShareVariant}
onclick={() => modalManager.show(SharingOptionsModal, { albumId: album.id })}
/>
<ActionButton action={Share} />
</div> </div>
{/if} {/if}
<AlbumDescription <AlbumDescription
@@ -508,7 +499,7 @@
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === AlbumPageViewMode.VIEW} {#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
{#snippet trailing()} {#snippet trailing()}
<ActionButton action={Cast} /> <ActionButton action={Cast} />
@@ -2,11 +2,8 @@
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
@@ -37,6 +34,7 @@
mdiChevronLeft, mdiChevronLeft,
mdiChevronRight, mdiChevronRight,
mdiChevronUp, mdiChevronUp,
mdiClose,
mdiDotsVertical, mdiDotsVertical,
mdiHeart, mdiHeart,
mdiHeartOutline, mdiHeartOutline,
@@ -54,6 +52,8 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
let memoryGallery: HTMLElement | undefined = $state(); let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state(); let memoryWrapper: HTMLElement | undefined = $state();
@@ -327,8 +327,8 @@
/> />
{#if assetMultiSelectManager.selectionActive} {#if assetMultiSelectManager.selectionActive}
<div class="dark sticky top-0 z-1"> <div class="sticky top-0 z-1 dark">
<AssetSelectControlBar forceDark> <AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)} {@const Actions = getAssetBulkActions($t)}
<CreateSharedLink /> <CreateSharedLink />
<IconButton <IconButton
@@ -365,22 +365,33 @@
<section <section
id="memory-viewer" id="memory-viewer"
class="w-full bg-immich-dark-gray" class="dark w-full text-white bg-immich-dark-gray"
bind:this={memoryWrapper} bind:this={memoryWrapper}
bind:clientHeight={viewport.height} bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
> >
{#if current} {#if current}
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow> <div
{#snippet leading()} class="max-md:h-auto max-md:flex-col dark grid grid-cols-[100%] md:grid-cols-[25%_50%_25%] px-2 py-2 md:px-4 md:py-4"
{#if current} >
{#if current}
<div class="flex gap-2 md:gap-6 items-center">
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiClose}
aria-label={$t('close')}
size="large"
onclick={() => goto(Route.photos())}
/>
<p class="text-lg"> <p class="text-lg">
{$memoryLaneTitle(current.memory)} {$memoryLaneTitle(current.memory)}
</p> </p>
{/if} </div>
{/snippet} {/if}
<div class="dark flex place-content-center place-items-center gap-2"> <div class="dark flex w-full place-content-center place-items-center gap-2">
<IconButton <IconButton
shape="round" shape="round"
variant="ghost" variant="ghost"
@@ -438,7 +449,7 @@
</media-mute-button> </media-mute-button>
{/if} {/if}
</div> </div>
</ControlAppBar> </div>
{#if galleryInView} {#if galleryInView}
<div <div
@@ -462,7 +473,7 @@
</div> </div>
{/if} {/if}
<!-- Viewer --> <!-- Viewer -->
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}> <section class="overflow-hidden pt-6 md:pt-0" bind:clientHeight={viewerHeight}>
<div <div
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]" class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
> >
@@ -47,7 +47,7 @@
<DownloadAction /> <DownloadAction />
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
{#snippet leading()} {#snippet leading()}
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg"> <p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
{$t('partner_list_user_photos', { values: { user: data.partner.name } })} {$t('partner_list_user_photos', { values: { user: data.partner.name } })}
+1 -1
View File
@@ -231,7 +231,7 @@
} }
const personWithSimilarName = await findPeopleWithSimilarName(name, targetPerson.id); const personWithSimilarName = await findPeopleWithSimilarName(name, targetPerson.id);
if (personWithSimilarName && personWithSimilarName.faceClusterId !== targetPerson.faceClusterId) { if (personWithSimilarName) {
personMerge1 = targetPerson; personMerge1 = targetPerson;
personMerge2 = personWithSimilarName; personMerge2 = personWithSimilarName;
potentialMergePeople = people potentialMergePeople = people
@@ -5,9 +5,6 @@
import { listNavigation } from '$lib/actions/list-navigation'; import { listNavigation } from '$lib/actions/list-navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import EditNameInput from './EditNameInput.svelte';
import MergeFaceSelector from './MergeFaceSelector.svelte';
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
@@ -54,6 +51,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import EditNameInput from './EditNameInput.svelte';
import MergeFaceSelector from './MergeFaceSelector.svelte';
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -493,7 +493,7 @@
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === PersonPageViewMode.VIEW_ASSETS} {#if viewMode === PersonPageViewMode.VIEW_ASSETS}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
{#snippet trailing()} {#snippet trailing()}
<ContextMenuButton <ContextMenuButton
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]} items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
@@ -68,7 +68,7 @@
try { try {
let results = await mergePerson({ let results = await mergePerson({
id: person.id, id: person.id,
mergeFaceClusterDto: { ids: selectedPeople.map(({ id }) => id) }, mergePersonDto: { ids: selectedPeople.map(({ id }) => id) },
}); });
const mergedPerson = await getPerson({ id: person.id }); const mergedPerson = await getPerson({ id: person.id });
const count = results.filter(({ success }) => success).length; const count = results.filter(({ success }) => success).length;
@@ -387,8 +387,7 @@
{:else} {:else}
<div class="fixed inset-s-0 top-0 z-2 w-full"> <div class="fixed inset-s-0 top-0 z-2 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}> <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div> <div class="mx-auto w-full max-w-2xl pe-2">
<div class="w-full flex-1 ps-4">
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} /> <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
</div> </div>
</ControlAppBar> </ControlAppBar>