mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 02:27:08 -04:00 
			
		
		
		
	feat: persistent memories (#15953)
feat: memories refactor chore: use heart as favorite icon fix: linting
This commit is contained in:
		
							parent
							
								
									502f6e020d
								
							
						
					
					
						commit
						d350022dec
					
				| @ -352,6 +352,8 @@ | ||||
|     "version_check_enabled_description": "Enable version check", | ||||
|     "version_check_implications": "The version check feature relies on periodic communication with github.com", | ||||
|     "version_check_settings": "Version Check", | ||||
|     "memory_cleanup_job": "Memory cleanup", | ||||
|     "memory_generate_job": "Memory generation", | ||||
|     "version_check_settings_description": "Enable/disable the new version notification", | ||||
|     "video_conversion_job": "Transcode videos", | ||||
|     "video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices" | ||||
| @ -1076,6 +1078,8 @@ | ||||
|   "remove_url": "Remove URL", | ||||
|   "remove_user": "Remove user", | ||||
|   "removed_api_key": "Removed API Key: {name}", | ||||
|   "removed_memory": "Removed memory", | ||||
|   "removed_photo_from_memory": "Removed photo from memory", | ||||
|   "removed_from_archive": "Removed from archive", | ||||
|   "removed_from_favorites": "Removed from favorites", | ||||
|   "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", | ||||
|  | ||||
							
								
								
									
										37
									
								
								mobile/openapi/lib/api/memories_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										37
									
								
								mobile/openapi/lib/api/memories_api.dart
									
									
									
										generated
									
									
									
								
							| @ -262,7 +262,16 @@ class MemoriesApi { | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'GET /memories' operation and returns the [Response]. | ||||
|   Future<Response> searchMemoriesWithHttpInfo() async { | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [DateTime] for_: | ||||
|   /// | ||||
|   /// * [bool] isSaved: | ||||
|   /// | ||||
|   /// * [bool] isTrashed: | ||||
|   /// | ||||
|   /// * [MemoryType] type: | ||||
|   Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/memories'; | ||||
| 
 | ||||
| @ -273,6 +282,19 @@ class MemoriesApi { | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     if (for_ != null) { | ||||
|       queryParams.addAll(_queryParams('', 'for', for_)); | ||||
|     } | ||||
|     if (isSaved != null) { | ||||
|       queryParams.addAll(_queryParams('', 'isSaved', isSaved)); | ||||
|     } | ||||
|     if (isTrashed != null) { | ||||
|       queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); | ||||
|     } | ||||
|     if (type != null) { | ||||
|       queryParams.addAll(_queryParams('', 'type', type)); | ||||
|     } | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| 
 | ||||
| @ -287,8 +309,17 @@ class MemoriesApi { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Future<List<MemoryResponseDto>?> searchMemories() async { | ||||
|     final response = await searchMemoriesWithHttpInfo(); | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [DateTime] for_: | ||||
|   /// | ||||
|   /// * [bool] isSaved: | ||||
|   /// | ||||
|   /// * [bool] isTrashed: | ||||
|   /// | ||||
|   /// * [MemoryType] type: | ||||
|   Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { | ||||
|     final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|  | ||||
							
								
								
									
										6
									
								
								mobile/openapi/lib/model/manual_job_name.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/lib/model/manual_job_name.dart
									
									
									
										generated
									
									
									
								
							| @ -26,12 +26,16 @@ class ManualJobName { | ||||
|   static const personCleanup = ManualJobName._(r'person-cleanup'); | ||||
|   static const tagCleanup = ManualJobName._(r'tag-cleanup'); | ||||
|   static const userCleanup = ManualJobName._(r'user-cleanup'); | ||||
|   static const memoryCleanup = ManualJobName._(r'memory-cleanup'); | ||||
|   static const memoryCreate = ManualJobName._(r'memory-create'); | ||||
| 
 | ||||
|   /// List of all possible values in this [enum][ManualJobName]. | ||||
|   static const values = <ManualJobName>[ | ||||
|     personCleanup, | ||||
|     tagCleanup, | ||||
|     userCleanup, | ||||
|     memoryCleanup, | ||||
|     memoryCreate, | ||||
|   ]; | ||||
| 
 | ||||
|   static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); | ||||
| @ -73,6 +77,8 @@ class ManualJobNameTypeTransformer { | ||||
|         case r'person-cleanup': return ManualJobName.personCleanup; | ||||
|         case r'tag-cleanup': return ManualJobName.tagCleanup; | ||||
|         case r'user-cleanup': return ManualJobName.userCleanup; | ||||
|         case r'memory-cleanup': return ManualJobName.memoryCleanup; | ||||
|         case r'memory-create': return ManualJobName.memoryCreate; | ||||
|         default: | ||||
|           if (!allowNull) { | ||||
|             throw ArgumentError('Unknown enum value to decode: $data'); | ||||
|  | ||||
							
								
								
									
										36
									
								
								mobile/openapi/lib/model/memory_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										36
									
								
								mobile/openapi/lib/model/memory_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @ -17,11 +17,13 @@ class MemoryResponseDto { | ||||
|     required this.createdAt, | ||||
|     required this.data, | ||||
|     this.deletedAt, | ||||
|     this.hideAt, | ||||
|     required this.id, | ||||
|     required this.isSaved, | ||||
|     required this.memoryAt, | ||||
|     required this.ownerId, | ||||
|     this.seenAt, | ||||
|     this.showAt, | ||||
|     required this.type, | ||||
|     required this.updatedAt, | ||||
|   }); | ||||
| @ -40,6 +42,14 @@ class MemoryResponseDto { | ||||
|   /// | ||||
|   DateTime? deletedAt; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   DateTime? hideAt; | ||||
| 
 | ||||
|   String id; | ||||
| 
 | ||||
|   bool isSaved; | ||||
| @ -56,6 +66,14 @@ class MemoryResponseDto { | ||||
|   /// | ||||
|   DateTime? seenAt; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   DateTime? showAt; | ||||
| 
 | ||||
|   MemoryType type; | ||||
| 
 | ||||
|   DateTime updatedAt; | ||||
| @ -66,11 +84,13 @@ class MemoryResponseDto { | ||||
|     other.createdAt == createdAt && | ||||
|     other.data == data && | ||||
|     other.deletedAt == deletedAt && | ||||
|     other.hideAt == hideAt && | ||||
|     other.id == id && | ||||
|     other.isSaved == isSaved && | ||||
|     other.memoryAt == memoryAt && | ||||
|     other.ownerId == ownerId && | ||||
|     other.seenAt == seenAt && | ||||
|     other.showAt == showAt && | ||||
|     other.type == type && | ||||
|     other.updatedAt == updatedAt; | ||||
| 
 | ||||
| @ -81,16 +101,18 @@ class MemoryResponseDto { | ||||
|     (createdAt.hashCode) + | ||||
|     (data.hashCode) + | ||||
|     (deletedAt == null ? 0 : deletedAt!.hashCode) + | ||||
|     (hideAt == null ? 0 : hideAt!.hashCode) + | ||||
|     (id.hashCode) + | ||||
|     (isSaved.hashCode) + | ||||
|     (memoryAt.hashCode) + | ||||
|     (ownerId.hashCode) + | ||||
|     (seenAt == null ? 0 : seenAt!.hashCode) + | ||||
|     (showAt == null ? 0 : showAt!.hashCode) + | ||||
|     (type.hashCode) + | ||||
|     (updatedAt.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, type=$type, updatedAt=$updatedAt]'; | ||||
|   String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, hideAt=$hideAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, showAt=$showAt, type=$type, updatedAt=$updatedAt]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @ -101,6 +123,11 @@ class MemoryResponseDto { | ||||
|       json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
|     //  json[r'deletedAt'] = null; | ||||
|     } | ||||
|     if (this.hideAt != null) { | ||||
|       json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
|     //  json[r'hideAt'] = null; | ||||
|     } | ||||
|       json[r'id'] = this.id; | ||||
|       json[r'isSaved'] = this.isSaved; | ||||
| @ -110,6 +137,11 @@ class MemoryResponseDto { | ||||
|       json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
|     //  json[r'seenAt'] = null; | ||||
|     } | ||||
|     if (this.showAt != null) { | ||||
|       json[r'showAt'] = this.showAt!.toUtc().toIso8601String(); | ||||
|     } else { | ||||
|     //  json[r'showAt'] = null; | ||||
|     } | ||||
|       json[r'type'] = this.type; | ||||
|       json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); | ||||
| @ -129,11 +161,13 @@ class MemoryResponseDto { | ||||
|         createdAt: mapDateTime(json, r'createdAt', r'')!, | ||||
|         data: OnThisDayDto.fromJson(json[r'data'])!, | ||||
|         deletedAt: mapDateTime(json, r'deletedAt', r''), | ||||
|         hideAt: mapDateTime(json, r'hideAt', r''), | ||||
|         id: mapValueOfType<String>(json, r'id')!, | ||||
|         isSaved: mapValueOfType<bool>(json, r'isSaved')!, | ||||
|         memoryAt: mapDateTime(json, r'memoryAt', r'')!, | ||||
|         ownerId: mapValueOfType<String>(json, r'ownerId')!, | ||||
|         seenAt: mapDateTime(json, r'seenAt', r''), | ||||
|         showAt: mapDateTime(json, r'showAt', r''), | ||||
|         type: MemoryType.fromJson(json[r'type'])!, | ||||
|         updatedAt: mapDateTime(json, r'updatedAt', r'')!, | ||||
|       ); | ||||
|  | ||||
| @ -3146,7 +3146,41 @@ | ||||
|     "/memories": { | ||||
|       "get": { | ||||
|         "operationId": "searchMemories", | ||||
|         "parameters": [], | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "for", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "format": "date-time", | ||||
|               "type": "string" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "isSaved", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "isTrashed", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "type", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "$ref": "#/components/schemas/MemoryType" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "content": { | ||||
| @ -9882,7 +9916,9 @@ | ||||
|         "enum": [ | ||||
|           "person-cleanup", | ||||
|           "tag-cleanup", | ||||
|           "user-cleanup" | ||||
|           "user-cleanup", | ||||
|           "memory-cleanup", | ||||
|           "memory-create" | ||||
|         ], | ||||
|         "type": "string" | ||||
|       }, | ||||
| @ -10039,6 +10075,10 @@ | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "hideAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "id": { | ||||
|             "type": "string" | ||||
|           }, | ||||
| @ -10056,6 +10096,10 @@ | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "showAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "type": { | ||||
|             "allOf": [ | ||||
|               { | ||||
|  | ||||
| @ -640,11 +640,13 @@ export type MemoryResponseDto = { | ||||
|     createdAt: string; | ||||
|     data: OnThisDayDto; | ||||
|     deletedAt?: string; | ||||
|     hideAt?: string; | ||||
|     id: string; | ||||
|     isSaved: boolean; | ||||
|     memoryAt: string; | ||||
|     ownerId: string; | ||||
|     seenAt?: string; | ||||
|     showAt?: string; | ||||
|     "type": MemoryType; | ||||
|     updatedAt: string; | ||||
| }; | ||||
| @ -2222,11 +2224,21 @@ export function reverseGeocode({ lat, lon }: { | ||||
|         ...opts | ||||
|     })); | ||||
| } | ||||
| export function searchMemories(opts?: Oazapfts.RequestOpts) { | ||||
| export function searchMemories({ $for, isSaved, isTrashed, $type }: { | ||||
|     $for?: string; | ||||
|     isSaved?: boolean; | ||||
|     isTrashed?: boolean; | ||||
|     $type?: MemoryType; | ||||
| }, opts?: Oazapfts.RequestOpts) { | ||||
|     return oazapfts.ok(oazapfts.fetchJson<{ | ||||
|         status: 200; | ||||
|         data: MemoryResponseDto[]; | ||||
|     }>("/memories", { | ||||
|     }>(`/memories${QS.query(QS.explode({ | ||||
|         "for": $for, | ||||
|         isSaved, | ||||
|         isTrashed, | ||||
|         "type": $type | ||||
|     }))}`, {
 | ||||
|         ...opts | ||||
|     })); | ||||
| } | ||||
| @ -3565,7 +3577,9 @@ export enum AssetMediaSize { | ||||
| export enum ManualJobName { | ||||
|     PersonCleanup = "person-cleanup", | ||||
|     TagCleanup = "tag-cleanup", | ||||
|     UserCleanup = "user-cleanup" | ||||
|     UserCleanup = "user-cleanup", | ||||
|     MemoryCleanup = "memory-cleanup", | ||||
|     MemoryCreate = "memory-create" | ||||
| } | ||||
| export enum JobName { | ||||
|     ThumbnailGeneration = "thumbnailGeneration", | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; | ||||
| import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; | ||||
| import { AuthDto } from 'src/dtos/auth.dto'; | ||||
| import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; | ||||
| import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; | ||||
| import { Permission } from 'src/enum'; | ||||
| import { Auth, Authenticated } from 'src/middleware/auth.guard'; | ||||
| import { MemoryService } from 'src/services/memory.service'; | ||||
| @ -15,8 +15,8 @@ export class MemoryController { | ||||
| 
 | ||||
|   @Get() | ||||
|   @Authenticated({ permission: Permission.MEMORY_READ }) | ||||
|   searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> { | ||||
|     return this.service.search(auth); | ||||
|   searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> { | ||||
|     return this.service.search(auth, dto); | ||||
|   } | ||||
| 
 | ||||
|   @Post() | ||||
|  | ||||
							
								
								
									
										2
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -227,11 +227,13 @@ export interface Memories { | ||||
|   createdAt: Generated<Timestamp>; | ||||
|   data: Json; | ||||
|   deletedAt: Timestamp | null; | ||||
|   hideAt: Timestamp | null; | ||||
|   id: Generated<string>; | ||||
|   isSaved: Generated<boolean>; | ||||
|   memoryAt: Timestamp; | ||||
|   ownerId: string; | ||||
|   seenAt: Timestamp | null; | ||||
|   showAt: Timestamp | null; | ||||
|   type: string; | ||||
|   updatedAt: Generated<Timestamp>; | ||||
| } | ||||
|  | ||||
| @ -5,7 +5,7 @@ import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; | ||||
| import { AssetEntity } from 'src/entities/asset.entity'; | ||||
| import { MemoryType } from 'src/enum'; | ||||
| import { MemoryItem } from 'src/types'; | ||||
| import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; | ||||
| import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; | ||||
| 
 | ||||
| class MemoryBaseDto { | ||||
|   @ValidateBoolean({ optional: true }) | ||||
| @ -15,6 +15,22 @@ class MemoryBaseDto { | ||||
|   seenAt?: Date; | ||||
| } | ||||
| 
 | ||||
| export class MemorySearchDto { | ||||
|   @Optional() | ||||
|   @IsEnum(MemoryType) | ||||
|   @ApiProperty({ enum: MemoryType, enumName: 'MemoryType' }) | ||||
|   type?: MemoryType; | ||||
| 
 | ||||
|   @ValidateDate({ optional: true }) | ||||
|   for?: Date; | ||||
| 
 | ||||
|   @ValidateBoolean({ optional: true }) | ||||
|   isTrashed?: boolean; | ||||
| 
 | ||||
|   @ValidateBoolean({ optional: true }) | ||||
|   isSaved?: boolean; | ||||
| } | ||||
| 
 | ||||
| class OnThisDayDto { | ||||
|   @IsInt() | ||||
|   @IsPositive() | ||||
| @ -62,6 +78,8 @@ export class MemoryResponseDto { | ||||
|   deletedAt?: Date; | ||||
|   memoryAt!: Date; | ||||
|   seenAt?: Date; | ||||
|   showAt?: Date; | ||||
|   hideAt?: Date; | ||||
|   ownerId!: string; | ||||
|   @ApiProperty({ enumName: 'MemoryType', enum: MemoryType }) | ||||
|   type!: MemoryType; | ||||
| @ -78,6 +96,8 @@ export const mapMemory = (entity: MemoryItem): MemoryResponseDto => { | ||||
|     deletedAt: entity.deletedAt ?? undefined, | ||||
|     memoryAt: entity.memoryAt, | ||||
|     seenAt: entity.seenAt ?? undefined, | ||||
|     showAt: entity.showAt ?? undefined, | ||||
|     hideAt: entity.hideAt ?? undefined, | ||||
|     ownerId: entity.ownerId, | ||||
|     type: entity.type as MemoryType, | ||||
|     data: entity.data as unknown as MemoryData, | ||||
|  | ||||
| @ -53,6 +53,12 @@ export class MemoryEntity<T extends MemoryType = MemoryType> { | ||||
|   @Column({ type: 'timestamptz' }) | ||||
|   memoryAt!: Date; | ||||
| 
 | ||||
|   @Column({ type: 'timestamptz', nullable: true }) | ||||
|   showAt?: Date; | ||||
| 
 | ||||
|   @Column({ type: 'timestamptz', nullable: true }) | ||||
|   hideAt?: Date; | ||||
| 
 | ||||
|   /** when the user last viewed the memory */ | ||||
|   @Column({ type: 'timestamptz', nullable: true }) | ||||
|   seenAt?: Date; | ||||
|  | ||||
| @ -14,6 +14,10 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat | ||||
| 
 | ||||
| export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; | ||||
| export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> }; | ||||
| export type MemoriesState = { | ||||
|   /** memories have already been created through this date */ | ||||
|   lastOnThisDayDate: string; | ||||
| }; | ||||
| 
 | ||||
| export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> { | ||||
|   [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; | ||||
| @ -23,4 +27,5 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, | ||||
|   [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>; | ||||
|   [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>; | ||||
|   [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; | ||||
|   [SystemMetadataKey.MEMORIES_STATE]: MemoriesState; | ||||
| } | ||||
|  | ||||
| @ -187,6 +187,7 @@ export enum StorageFolder { | ||||
| export enum SystemMetadataKey { | ||||
|   REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', | ||||
|   FACIAL_RECOGNITION_STATE = 'facial-recognition-state', | ||||
|   MEMORIES_STATE = 'memories-state', | ||||
|   ADMIN_ONBOARDING = 'admin-onboarding', | ||||
|   SYSTEM_CONFIG = 'system-config', | ||||
|   SYSTEM_FLAGS = 'system-flags', | ||||
| @ -233,6 +234,8 @@ export enum ManualJobName { | ||||
|   PERSON_CLEANUP = 'person-cleanup', | ||||
|   TAG_CLEANUP = 'tag-cleanup', | ||||
|   USER_CLEANUP = 'user-cleanup', | ||||
|   MEMORY_CLEANUP = 'memory-cleanup', | ||||
|   MEMORY_CREATE = 'memory-create', | ||||
| } | ||||
| 
 | ||||
| export enum AssetPathType { | ||||
| @ -477,6 +480,10 @@ export enum JobName { | ||||
|   CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', | ||||
|   CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', | ||||
| 
 | ||||
|   // memories
 | ||||
|   MEMORIES_CLEANUP = 'memories-cleanup', | ||||
|   MEMORIES_CREATE = 'memories-create', | ||||
| 
 | ||||
|   // smart search
 | ||||
|   QUEUE_SMART_SEARCH = 'queue-smart-search', | ||||
|   SMART_SEARCH = 'smart-search', | ||||
|  | ||||
| @ -0,0 +1,16 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
| 
 | ||||
| export class AddMemoryShowHideDates1739824470990 implements MigrationInterface { | ||||
|     name = 'AddMemoryShowHideDates1739824470990' | ||||
| 
 | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`ALTER TABLE "memories" ADD "showAt" TIMESTAMP WITH TIME ZONE`); | ||||
|         await queryRunner.query(`ALTER TABLE "memories" ADD "hideAt" TIMESTAMP WITH TIME ZONE`); | ||||
|     } | ||||
| 
 | ||||
|     public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "hideAt"`); | ||||
|         await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "showAt"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -1,12 +1,68 @@ | ||||
| -- NOTE: This file is auto generated by ./sql-generator | ||||
| 
 | ||||
| -- MemoryRepository.cleanup | ||||
| delete from "memories" | ||||
| where | ||||
|   "createdAt" < $1 | ||||
|   and "isSaved" = $2 | ||||
| 
 | ||||
| -- MemoryRepository.search | ||||
| select | ||||
|   * | ||||
|   "memories".*, | ||||
|   ( | ||||
|     select | ||||
|       coalesce(json_agg(agg), '[]') | ||||
|     from | ||||
|       ( | ||||
|         select | ||||
|           "assets".* | ||||
|         from | ||||
|           "assets" | ||||
|           inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId" | ||||
|         where | ||||
|           "memories_assets_assets"."memoriesId" = "memories"."id" | ||||
|           and "assets"."deletedAt" is null | ||||
|       ) as agg | ||||
|   ) as "assets" | ||||
| from | ||||
|   "memories" | ||||
| where | ||||
|   "ownerId" = $1 | ||||
|   "deletedAt" is null | ||||
|   and "ownerId" = $1 | ||||
| order by | ||||
|   "memoryAt" desc | ||||
| 
 | ||||
| -- MemoryRepository.search (date filter) | ||||
| select | ||||
|   "memories".*, | ||||
|   ( | ||||
|     select | ||||
|       coalesce(json_agg(agg), '[]') | ||||
|     from | ||||
|       ( | ||||
|         select | ||||
|           "assets".* | ||||
|         from | ||||
|           "assets" | ||||
|           inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId" | ||||
|         where | ||||
|           "memories_assets_assets"."memoriesId" = "memories"."id" | ||||
|           and "assets"."deletedAt" is null | ||||
|       ) as agg | ||||
|   ) as "assets" | ||||
| from | ||||
|   "memories" | ||||
| where | ||||
|   ( | ||||
|     "showAt" is null | ||||
|     or "showAt" <= $1 | ||||
|   ) | ||||
|   and ( | ||||
|     "hideAt" is null | ||||
|     or "hideAt" >= $2 | ||||
|   ) | ||||
|   and "deletedAt" is null | ||||
|   and "ownerId" = $3 | ||||
| order by | ||||
|   "memoryAt" desc | ||||
| 
 | ||||
|  | ||||
| @ -1,9 +1,11 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { Insertable, Kysely, Updateable } from 'kysely'; | ||||
| import { jsonArrayFrom } from 'kysely/helpers/postgres'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { InjectKysely } from 'nestjs-kysely'; | ||||
| import { DB, Memories } from 'src/db'; | ||||
| import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; | ||||
| import { MemorySearchDto } from 'src/dtos/memory.dto'; | ||||
| import { IBulkAsset } from 'src/types'; | ||||
| 
 | ||||
| @Injectable() | ||||
| @ -11,10 +13,40 @@ export class MemoryRepository implements IBulkAsset { | ||||
|   constructor(@InjectKysely() private db: Kysely<DB>) {} | ||||
| 
 | ||||
|   @GenerateSql({ params: [DummyValue.UUID] }) | ||||
|   search(ownerId: string) { | ||||
|   cleanup() { | ||||
|     return this.db | ||||
|       .deleteFrom('memories') | ||||
|       .where('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()) | ||||
|       .where('isSaved', '=', false) | ||||
|       .execute(); | ||||
|   } | ||||
| 
 | ||||
|   @GenerateSql( | ||||
|     { params: [DummyValue.UUID, {}] }, | ||||
|     { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, | ||||
|   ) | ||||
|   search(ownerId: string, dto: MemorySearchDto) { | ||||
|     return this.db | ||||
|       .selectFrom('memories') | ||||
|       .selectAll() | ||||
|       .selectAll('memories') | ||||
|       .select((eb) => | ||||
|         jsonArrayFrom( | ||||
|           eb | ||||
|             .selectFrom('assets') | ||||
|             .selectAll('assets') | ||||
|             .innerJoin('memories_assets_assets', 'assets.id', 'memories_assets_assets.assetsId') | ||||
|             .whereRef('memories_assets_assets.memoriesId', '=', 'memories.id') | ||||
|             .where('assets.deletedAt', 'is', null), | ||||
|         ).as('assets'), | ||||
|       ) | ||||
|       .$if(dto.isSaved !== undefined, (qb) => qb.where('isSaved', '=', dto.isSaved!)) | ||||
|       .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) | ||||
|       .$if(dto.for !== undefined, (qb) => | ||||
|         qb | ||||
|           .where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)])) | ||||
|           .where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])), | ||||
|       ) | ||||
|       .where('deletedAt', dto.isTrashed ? 'is not' : 'is', null) | ||||
|       .where('ownerId', '=', ownerId) | ||||
|       .orderBy('memoryAt', 'desc') | ||||
|       .execute(); | ||||
|  | ||||
| @ -40,6 +40,8 @@ describe(JobService.name, () => { | ||||
|         { name: JobName.ASSET_DELETION_CHECK }, | ||||
|         { name: JobName.USER_DELETE_CHECK }, | ||||
|         { name: JobName.PERSON_CLEANUP }, | ||||
|         { name: JobName.MEMORIES_CLEANUP }, | ||||
|         { name: JobName.MEMORIES_CREATE }, | ||||
|         { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, | ||||
|         { name: JobName.CLEAN_OLD_AUDIT_LOGS }, | ||||
|         { name: JobName.USER_SYNC_USAGE }, | ||||
|  | ||||
| @ -31,6 +31,14 @@ const asJobItem = (dto: JobCreateDto): JobItem => { | ||||
|       return { name: JobName.USER_DELETE_CHECK }; | ||||
|     } | ||||
| 
 | ||||
|     case ManualJobName.MEMORY_CLEANUP: { | ||||
|       return { name: JobName.MEMORIES_CLEANUP }; | ||||
|     } | ||||
| 
 | ||||
|     case ManualJobName.MEMORY_CREATE: { | ||||
|       return { name: JobName.MEMORIES_CREATE }; | ||||
|     } | ||||
| 
 | ||||
|     default: { | ||||
|       throw new BadRequestException('Invalid job name'); | ||||
|     } | ||||
| @ -207,6 +215,8 @@ export class JobService extends BaseService { | ||||
|       { name: JobName.ASSET_DELETION_CHECK }, | ||||
|       { name: JobName.USER_DELETE_CHECK }, | ||||
|       { name: JobName.PERSON_CLEANUP }, | ||||
|       { name: JobName.MEMORIES_CLEANUP }, | ||||
|       { name: JobName.MEMORIES_CREATE }, | ||||
|       { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, | ||||
|       { name: JobName.CLEAN_OLD_AUDIT_LOGS }, | ||||
|       { name: JobName.USER_SYNC_USAGE }, | ||||
|  | ||||
| @ -21,7 +21,7 @@ describe(MemoryService.name, () => { | ||||
|   describe('search', () => { | ||||
|     it('should search memories', async () => { | ||||
|       mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); | ||||
|       await expect(sut.search(authStub.admin)).resolves.toEqual( | ||||
|       await expect(sut.search(authStub.admin, {})).resolves.toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), | ||||
|           expect.objectContaining({ id: 'memoryEmpty', assets: [] }), | ||||
| @ -30,7 +30,7 @@ describe(MemoryService.name, () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('should map ', async () => { | ||||
|       await expect(sut.search(authStub.admin)).resolves.toEqual([]); | ||||
|       await expect(sut.search(authStub.admin, {})).resolves.toEqual([]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  | ||||
| @ -1,16 +1,84 @@ | ||||
| import { BadRequestException, Injectable } from '@nestjs/common'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { JsonObject } from 'src/db'; | ||||
| import { OnJob } from 'src/decorators'; | ||||
| import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; | ||||
| import { AuthDto } from 'src/dtos/auth.dto'; | ||||
| import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; | ||||
| import { Permission } from 'src/enum'; | ||||
| import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; | ||||
| import { OnThisDayData } from 'src/entities/memory.entity'; | ||||
| import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; | ||||
| import { BaseService } from 'src/services/base.service'; | ||||
| import { addAssets, removeAssets } from 'src/utils/asset.util'; | ||||
| import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; | ||||
| 
 | ||||
| const DAYS = 3; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class MemoryService extends BaseService { | ||||
|   async search(auth: AuthDto) { | ||||
|     const memories = await this.memoryRepository.search(auth.user.id); | ||||
|   @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK }) | ||||
|   async onMemoriesCreate() { | ||||
|     const users = await this.userRepository.getList({ withDeleted: false }); | ||||
|     const userMap: Record<string, string[]> = {}; | ||||
|     for (const user of users) { | ||||
|       const partnerIds = await getMyPartnerIds({ | ||||
|         userId: user.id, | ||||
|         repository: this.partnerRepository, | ||||
|         timelineEnabled: true, | ||||
|       }); | ||||
|       userMap[user.id] = [user.id, ...partnerIds]; | ||||
|     } | ||||
| 
 | ||||
|     const start = DateTime.utc().startOf('day').minus({ days: DAYS }); | ||||
| 
 | ||||
|     const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); | ||||
|     let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start; | ||||
| 
 | ||||
|     // generate a memory +/- X days from today
 | ||||
|     for (let i = 0; i <= DAYS * 2 + 1; i++) { | ||||
|       const target = start.plus({ days: i }); | ||||
|       if (lastOnThisDayDate > target) { | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       const showAt = target.startOf('day').toISO(); | ||||
|       const hideAt = target.endOf('day').toISO(); | ||||
| 
 | ||||
|       this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`); | ||||
| 
 | ||||
|       for (const [userId, userIds] of Object.entries(userMap)) { | ||||
|         const memories = await this.assetRepository.getByDayOfYear(userIds, target); | ||||
| 
 | ||||
|         for (const memory of memories) { | ||||
|           const data: OnThisDayData = { year: target.year - memory.yearsAgo }; | ||||
|           await this.memoryRepository.create( | ||||
|             { | ||||
|               ownerId: userId, | ||||
|               type: MemoryType.ON_THIS_DAY, | ||||
|               data, | ||||
|               memoryAt: target.minus({ years: memory.yearsAgo }).toISO(), | ||||
|               showAt, | ||||
|               hideAt, | ||||
|             }, | ||||
|             new Set(memory.assets.map(({ id }) => id)), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { | ||||
|         ...state, | ||||
|         lastOnThisDayDate: target.toISO(), | ||||
|       }); | ||||
| 
 | ||||
|       lastOnThisDayDate = target; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK }) | ||||
|   async onMemoriesCleanup() { | ||||
|     await this.memoryRepository.cleanup(); | ||||
|   } | ||||
| 
 | ||||
|   async search(auth: AuthDto, dto: MemorySearchDto) { | ||||
|     const memories = await this.memoryRepository.search(auth.user.id, dto); | ||||
|     return memories.map((memory) => mapMemory(memory)); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -326,6 +326,10 @@ export type JobItem = | ||||
|   | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } | ||||
|   | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } | ||||
| 
 | ||||
|   // Memories
 | ||||
|   | { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob } | ||||
|   | { name: JobName.MEMORIES_CREATE; data?: IBaseJob } | ||||
| 
 | ||||
|   // Filesystem
 | ||||
|   | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } | ||||
| 
 | ||||
| @ -357,7 +361,11 @@ export type JobItem = | ||||
|   | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } | ||||
| 
 | ||||
|   // Version check
 | ||||
|   | { name: JobName.VERSION_CHECK; data: IBaseJob }; | ||||
|   | { name: JobName.VERSION_CHECK; data: IBaseJob } | ||||
| 
 | ||||
|   // Memories
 | ||||
|   | { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob } | ||||
|   | { name: JobName.MEMORIES_CREATE; data?: IBaseJob }; | ||||
| 
 | ||||
| export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; | ||||
| 
 | ||||
|  | ||||
| @ -12,5 +12,6 @@ export const newMemoryRepositoryMock = (): Mocked<RepositoryInterface<MemoryRepo | ||||
|     getAssetIds: vitest.fn().mockResolvedValue(new Set()), | ||||
|     addAssetIds: vitest.fn(), | ||||
|     removeAssetIds: vitest.fn(), | ||||
|     cleanup: vitest.fn(), | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @ -13,25 +13,45 @@ | ||||
|   import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; | ||||
|   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; | ||||
|   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
|   import { cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { | ||||
|     notificationController, | ||||
|     NotificationType, | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { AppRoute, QueryParameter } from '$lib/constants'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { type Viewport } from '$lib/stores/assets.store'; | ||||
|   import { memoryStore } from '$lib/stores/memory.store'; | ||||
|   import { loadMemories, memoryStore } from '$lib/stores/memory.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; | ||||
|   import { cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { fromLocalDateTime } from '$lib/utils/timeline-util'; | ||||
|   import { AssetMediaSize, getMemoryLane, type AssetResponseDto, type MemoryLaneResponseDto } from '@immich/sdk'; | ||||
|   import { | ||||
|     AssetMediaSize, | ||||
|     deleteMemory, | ||||
|     removeMemoryAssets, | ||||
|     updateMemory, | ||||
|     type AssetResponseDto, | ||||
|     type MemoryResponseDto, | ||||
|   } from '@immich/sdk'; | ||||
|   import { IconButton } from '@immich/ui'; | ||||
|   import { | ||||
|     mdiCardsOutline, | ||||
|     mdiChevronDown, | ||||
|     mdiChevronLeft, | ||||
|     mdiChevronRight, | ||||
|     mdiChevronUp, | ||||
|     mdiDotsVertical, | ||||
|     mdiHeart, | ||||
|     mdiHeartOutline, | ||||
|     mdiImageMinusOutline, | ||||
|     mdiImageSearch, | ||||
|     mdiPause, | ||||
|     mdiPlay, | ||||
| @ -45,9 +65,6 @@ | ||||
|   import { tweened } from 'svelte/motion'; | ||||
|   import { derived as storeDerived } from 'svelte/store'; | ||||
|   import { fade } from 'svelte/transition'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   type MemoryIndex = { | ||||
|     memoryIndex: number; | ||||
| @ -55,20 +72,20 @@ | ||||
|   }; | ||||
| 
 | ||||
|   type MemoryAsset = MemoryIndex & { | ||||
|     memory: MemoryLaneResponseDto; | ||||
|     memory: MemoryResponseDto; | ||||
|     asset: AssetResponseDto; | ||||
|     previousMemory?: MemoryLaneResponseDto; | ||||
|     previousMemory?: MemoryResponseDto; | ||||
|     previous?: MemoryAsset; | ||||
|     next?: MemoryAsset; | ||||
|     nextMemory?: MemoryLaneResponseDto; | ||||
|     nextMemory?: MemoryResponseDto; | ||||
|   }; | ||||
| 
 | ||||
|   let memoryGallery: HTMLElement | undefined = $state(); | ||||
|   let memoryWrapper: HTMLElement | undefined = $state(); | ||||
|   let galleryInView = $state(false); | ||||
|   let paused = $state(false); | ||||
|   let current: MemoryAsset | undefined = $state(undefined); | ||||
|   // let memories: MemoryAsset[] = []; | ||||
|   let current = $state<MemoryAsset | undefined>(undefined); | ||||
|   let isSaved = $derived(current?.memory.isSaved); | ||||
|   let resetPromise = $state(Promise.resolve()); | ||||
| 
 | ||||
|   const { isViewing } = assetViewingStore; | ||||
| @ -168,6 +185,7 @@ | ||||
|     } | ||||
|     current.memory.assets = current.memory.assets; | ||||
|   }; | ||||
| 
 | ||||
|   const handleRemove = (ids: string[]) => { | ||||
|     if (!current) { | ||||
|       return; | ||||
| @ -186,13 +204,65 @@ | ||||
|     current = loadFromParams($memories, $page); | ||||
|   }; | ||||
| 
 | ||||
|   const handleDeleteMemoryAsset = async (current?: MemoryAsset) => { | ||||
|     if (!current) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (current.memory.assets.length === 1) { | ||||
|       return handleDeleteMemory(current); | ||||
|     } | ||||
| 
 | ||||
|     if (current.previous) { | ||||
|       current.previous.next = current.next; | ||||
|     } | ||||
|     if (current.next) { | ||||
|       current.next.previous = current.previous; | ||||
|     } | ||||
| 
 | ||||
|     current.memory.assets = current.memory.assets.filter((asset) => asset.id !== current.asset.id); | ||||
| 
 | ||||
|     $memoryStore = $memoryStore; | ||||
| 
 | ||||
|     await removeMemoryAssets({ id: current.memory.id, bulkIdsDto: { ids: [current.asset.id] } }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleDeleteMemory = async (current?: MemoryAsset) => { | ||||
|     if (!current) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     await deleteMemory({ id: current.memory.id }); | ||||
| 
 | ||||
|     notificationController.show({ message: $t('removed_memory'), type: NotificationType.Info }); | ||||
| 
 | ||||
|     await loadMemories(); | ||||
|     init(); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSaveMemory = async (current?: MemoryAsset) => { | ||||
|     if (!current) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     current.memory.isSaved = !current.memory.isSaved; | ||||
| 
 | ||||
|     await updateMemory({ | ||||
|       id: current.memory.id, | ||||
|       memoryUpdateDto: { | ||||
|         isSaved: current.memory.isSaved, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     notificationController.show({ | ||||
|       message: current.memory.isSaved ? $t('added_to_favorites') : $t('removed_from_favorites'), | ||||
|       type: NotificationType.Info, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   onMount(async () => { | ||||
|     if (!$memoryStore) { | ||||
|       const localTime = new Date(); | ||||
|       $memoryStore = await getMemoryLane({ | ||||
|         month: localTime.getMonth() + 1, | ||||
|         day: localTime.getDate(), | ||||
|       }); | ||||
|       await loadMemories(); | ||||
|     } | ||||
| 
 | ||||
|     init(); | ||||
| @ -268,7 +338,7 @@ | ||||
|       {#snippet leading()} | ||||
|         {#if current} | ||||
|           <p class="text-lg"> | ||||
|             {$memoryLaneTitle(current.memory.yearsAgo)} | ||||
|             {$memoryLaneTitle(current.memory)} | ||||
|           </p> | ||||
|         {/if} | ||||
|       {/snippet} | ||||
| @ -352,7 +422,7 @@ | ||||
|             {#if current.previousMemory} | ||||
|               <div class="absolute bottom-4 right-4 text-left text-white"> | ||||
|                 <p class="text-xs font-semibold text-gray-200">{$t('previous').toUpperCase()}</p> | ||||
|                 <p class="text-xl">{$memoryLaneTitle(current.previousMemory.yearsAgo)}</p> | ||||
|                 <p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p> | ||||
|               </div> | ||||
|             {/if} | ||||
|           </button> | ||||
| @ -374,18 +444,64 @@ | ||||
|             {/key} | ||||
| 
 | ||||
|             <div | ||||
|               class="absolute bottom-6 right-6 transition-all" | ||||
|               class="absolute bottom-0 right-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2" | ||||
|               class:opacity-0={galleryInView} | ||||
|               class:opacity-100={!galleryInView} | ||||
|             > | ||||
|               <CircleIconButton | ||||
|               <div class="flex"> | ||||
|                 <IconButton | ||||
|                   icon={isSaved ? mdiHeart : mdiHeartOutline} | ||||
|                   shape="round" | ||||
|                   variant="ghost" | ||||
|                   size="giant" | ||||
|                   color="secondary" | ||||
|                   aria-label={isSaved ? $t('unfavorite') : $t('favorite')} | ||||
|                   onclick={() => handleSaveMemory(current)} | ||||
|                   class="text-white dark:text-white" | ||||
|                 /> | ||||
|                 <!-- <IconButton | ||||
|                   icon={mdiShareVariantOutline} | ||||
|                   shape="round" | ||||
|                   variant="ghost" | ||||
|                   size="giant" | ||||
|                   color="secondary" | ||||
|                   aria-label={$t('share')} | ||||
|                 /> --> | ||||
|                 <ButtonContextMenu | ||||
|                   icon={mdiDotsVertical} | ||||
|                   title={$t('menu')} | ||||
|                   onclick={() => handleAction('pause')} | ||||
|                   direction="left" | ||||
|                   align="bottom-right" | ||||
|                   class="text-white dark:text-white" | ||||
|                 > | ||||
|                   <MenuOption | ||||
|                     onClick={() => handleDeleteMemory(current)} | ||||
|                     text={'Remove memory'} | ||||
|                     icon={mdiCardsOutline} | ||||
|                   /> | ||||
|                   <MenuOption | ||||
|                     onClick={() => handleDeleteMemoryAsset(current)} | ||||
|                     text={'Remove photo from this memory'} | ||||
|                     icon={mdiImageMinusOutline} | ||||
|                   /> | ||||
|                   <!-- shortcut={{ key: 'l', shift: shared }} --> | ||||
|                 </ButtonContextMenu> | ||||
|               </div> | ||||
| 
 | ||||
|               <div> | ||||
|                 <IconButton | ||||
|                   href="{AppRoute.PHOTOS}?at={current.asset.id}" | ||||
|                   icon={mdiImageSearch} | ||||
|                 title={$t('view_in_timeline')} | ||||
|                 color="light" | ||||
|                 onclick={() => {}} | ||||
|                   aria-label={$t('view_in_timeline')} | ||||
|                   color="secondary" | ||||
|                   variant="ghost" | ||||
|                   shape="round" | ||||
|                   size="giant" | ||||
|                   class="text-white dark:text-white" | ||||
|                 /> | ||||
|               </div> | ||||
|             </div> | ||||
|             <!-- CONTROL BUTTONS --> | ||||
|             {#if current.previous} | ||||
|               <div class="absolute top-1/2 left-0 ml-4"> | ||||
| @ -449,7 +565,7 @@ | ||||
|             {#if current.nextMemory} | ||||
|               <div class="absolute bottom-4 left-4 text-left text-white"> | ||||
|                 <p class="text-xs font-semibold text-gray-200">{$t('up_next').toUpperCase()}</p> | ||||
|                 <p class="text-xl">{$memoryLaneTitle(current.nextMemory.yearsAgo)}</p> | ||||
|                 <p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p> | ||||
|               </div> | ||||
|             {/if} | ||||
|           </button> | ||||
|  | ||||
| @ -2,20 +2,18 @@ | ||||
|   import { resizeObserver } from '$lib/actions/resize-observer'; | ||||
|   import Icon from '$lib/components/elements/icon.svelte'; | ||||
|   import { AppRoute, QueryParameter } from '$lib/constants'; | ||||
|   import { memoryStore } from '$lib/stores/memory.store'; | ||||
|   import { loadMemories, memoryStore } from '$lib/stores/memory.store'; | ||||
|   import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils'; | ||||
|   import { getAltText } from '$lib/utils/thumbnail-util'; | ||||
|   import { getMemoryLane } from '@immich/sdk'; | ||||
|   import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import { fade } from 'svelte/transition'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { fade } from 'svelte/transition'; | ||||
| 
 | ||||
|   let shouldRender = $derived($memoryStore?.length > 0); | ||||
| 
 | ||||
|   onMount(async () => { | ||||
|     const localTime = new Date(); | ||||
|     $memoryStore = await getMemoryLane({ month: localTime.getMonth() + 1, day: localTime.getDate() }); | ||||
|     await loadMemories(); | ||||
|   }); | ||||
| 
 | ||||
|   let memoryLaneElement: HTMLElement | undefined = $state(); | ||||
| @ -71,7 +69,7 @@ | ||||
|       </div> | ||||
|     {/if} | ||||
|     <div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}> | ||||
|       {#each $memoryStore as memory (memory.yearsAgo)} | ||||
|       {#each $memoryStore as memory} | ||||
|         {#if memory.assets.length > 0} | ||||
|           <a | ||||
|             class="memory-card relative mr-8 inline-block aspect-[3/4] md:aspect-video h-[215px] rounded-xl" | ||||
| @ -84,7 +82,7 @@ | ||||
|               draggable="false" | ||||
|             /> | ||||
|             <p class="absolute bottom-2 left-4 z-10 text-lg text-white"> | ||||
|               {$memoryLaneTitle(memory.yearsAgo)} | ||||
|               {$memoryLaneTitle(memory)} | ||||
|             </p> | ||||
|             <div | ||||
|               class="absolute left-0 top-0 z-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20" | ||||
|  | ||||
| @ -1,22 +1,23 @@ | ||||
| <script lang="ts"> | ||||
|   import { clickOutside } from '$lib/actions/click-outside'; | ||||
|   import { contextMenuNavigation } from '$lib/actions/context-menu-navigation'; | ||||
|   import { shortcuts } from '$lib/actions/shortcut'; | ||||
|   import CircleIconButton, { | ||||
|     type Color, | ||||
|     type Padding, | ||||
|   } from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; | ||||
|   import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; | ||||
|   import { | ||||
|     getContextMenuPositionFromBoundingRect, | ||||
|     getContextMenuPositionFromEvent, | ||||
|     type Align, | ||||
|   } from '$lib/utils/context-menu'; | ||||
|   import { generateId } from '$lib/utils/generate-id'; | ||||
|   import { contextMenuNavigation } from '$lib/actions/context-menu-navigation'; | ||||
|   import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; | ||||
|   import { clickOutside } from '$lib/actions/click-outside'; | ||||
|   import { shortcuts } from '$lib/actions/shortcut'; | ||||
|   import type { Snippet } from 'svelte'; | ||||
|   import type { HTMLAttributes } from 'svelte/elements'; | ||||
| 
 | ||||
|   interface Props { | ||||
|   type Props = { | ||||
|     icon: string; | ||||
|     title: string; | ||||
|     /** | ||||
| @ -36,7 +37,7 @@ | ||||
|     buttonClass?: string | undefined; | ||||
|     hideContent?: boolean; | ||||
|     children?: Snippet; | ||||
|   } | ||||
|   } & HTMLAttributes<HTMLDivElement>; | ||||
| 
 | ||||
|   let { | ||||
|     icon, | ||||
| @ -49,6 +50,7 @@ | ||||
|     buttonClass = undefined, | ||||
|     hideContent = false, | ||||
|     children, | ||||
|     ...restProps | ||||
|   }: Props = $props(); | ||||
| 
 | ||||
|   let isOpen = $state(false); | ||||
| @ -129,6 +131,7 @@ | ||||
|   }} | ||||
|   use:clickOutside={{ onOutclick: closeDropdown }} | ||||
|   onresize={onResize} | ||||
|   {...restProps} | ||||
| > | ||||
|   <div bind:this={buttonContainer}> | ||||
|     <CircleIconButton | ||||
|  | ||||
| @ -1,4 +1,11 @@ | ||||
| import type { MemoryLaneResponseDto } from '@immich/sdk'; | ||||
| import { asLocalTimeISO } from '$lib/utils/date-time'; | ||||
| import { searchMemories, type MemoryResponseDto } from '@immich/sdk'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { writable } from 'svelte/store'; | ||||
| 
 | ||||
| export const memoryStore = writable<MemoryLaneResponseDto[]>(); | ||||
| export const memoryStore = writable<MemoryResponseDto[]>(); | ||||
| 
 | ||||
| export const loadMemories = async () => { | ||||
|   const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) }); | ||||
|   memoryStore.set(memories); | ||||
| }; | ||||
|  | ||||
| @ -6,6 +6,7 @@ import { | ||||
|   AssetJobName, | ||||
|   AssetMediaSize, | ||||
|   JobName, | ||||
|   MemoryType, | ||||
|   finishOAuth, | ||||
|   getAssetOriginalPath, | ||||
|   getAssetPlaybackPath, | ||||
| @ -16,6 +17,7 @@ import { | ||||
|   linkOAuthAccount, | ||||
|   startOAuth, | ||||
|   unlinkOAuthAccount, | ||||
|   type MemoryResponseDto, | ||||
|   type PersonResponseDto, | ||||
|   type SharedLinkResponseDto, | ||||
|   type UserResponseDto, | ||||
| @ -320,7 +322,14 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => { | ||||
| }; | ||||
| 
 | ||||
| export const memoryLaneTitle = derived(t, ($t) => { | ||||
|   return (yearsAgo: number) => $t('years_ago', { values: { years: yearsAgo } }); | ||||
|   return (memory: MemoryResponseDto) => { | ||||
|     const now = new Date(); | ||||
|     if (memory.type === MemoryType.OnThisDay) { | ||||
|       return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } }); | ||||
|     } | ||||
| 
 | ||||
|     return $t('unknown'); | ||||
|   }; | ||||
| }); | ||||
| 
 | ||||
| export const withError = async <T>(fn: () => Promise<T>): Promise<[undefined, T] | [unknown, undefined]> => { | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| export type Align = 'middle' | 'top-left' | 'top-right'; | ||||
| export type Align = 'middle' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; | ||||
| 
 | ||||
| export type ContextMenuPosition = { x: number; y: number }; | ||||
| 
 | ||||
| @ -28,5 +28,11 @@ export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Ali | ||||
|     case 'top-right': { | ||||
|       return { x: rect.x + rect.width, y: rect.y }; | ||||
|     } | ||||
|     case 'bottom-left': { | ||||
|       return { x: rect.x, y: rect.y + rect.height }; | ||||
|     } | ||||
|     case 'bottom-right': { | ||||
|       return { x: rect.x + rect.width, y: rect.y + rect.height }; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -77,3 +77,11 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string | ||||
| 
 | ||||
|   return ''; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Use this to convert from "5pm EST" to "5pm UTC" | ||||
|  * | ||||
|  * Useful with some APIs where you want to query by "today", but the values in the database are stored as UTC | ||||
|  */ | ||||
| export const asLocalTimeISO = (date: DateTime<true>) => | ||||
|   (date.setZone('utc', { keepLocalTime: true }) as DateTime<true>).toISO(); | ||||
|  | ||||
| @ -44,6 +44,8 @@ | ||||
|     { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup }, | ||||
|     { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup }, | ||||
|     { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, | ||||
|     { title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup }, | ||||
|     { title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate }, | ||||
|   ].map(({ value, title }) => ({ id: value, label: title, value })); | ||||
| 
 | ||||
|   const handleCancel = () => (isOpen = false); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user