From 14792c423cbc8922c2b57812f9aa219da6e6a96c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 16:41:58 -0500 Subject: [PATCH] feat: memories refactor chore: use heart as favorite icon fix: linting --- i18n/en.json | 4 + mobile/openapi/lib/api/memories_api.dart | 37 +++- mobile/openapi/lib/model/manual_job_name.dart | 6 + .../lib/model/memory_response_dto.dart | 36 +++- open-api/immich-openapi-specs.json | 48 ++++- open-api/typescript-sdk/src/fetch-client.ts | 20 ++- server/src/controllers/memory.controller.ts | 8 +- server/src/db.d.ts | 2 + server/src/dtos/memory.dto.ts | 22 ++- server/src/entities/memory.entity.ts | 6 + server/src/entities/system-metadata.entity.ts | 5 + server/src/enum.ts | 7 + .../1739824470990-AddMemoryShowHideDates.ts | 16 ++ server/src/queries/memory.repository.sql | 60 ++++++- server/src/repositories/memory.repository.ts | 36 +++- server/src/services/job.service.spec.ts | 2 + server/src/services/job.service.ts | 10 ++ server/src/services/memory.service.spec.ts | 4 +- server/src/services/memory.service.ts | 78 +++++++- server/src/types.ts | 10 +- .../memory-page/memory-viewer.svelte | 170 +++++++++++++++--- .../components/photos-page/memory-lane.svelte | 12 +- .../context-menu/button-context-menu.svelte | 15 +- web/src/lib/stores/memory.store.ts | 11 +- web/src/lib/utils.ts | 11 +- web/src/lib/utils/context-menu.ts | 8 +- web/src/lib/utils/date-time.ts | 8 + web/src/routes/admin/jobs-status/+page.svelte | 2 + 28 files changed, 584 insertions(+), 70 deletions(-) create mode 100644 server/src/migrations/1739824470990-AddMemoryShowHideDates.ts diff --git a/i18n/en.json b/i18n/en.json index 72559d4502..8bb5bf3477 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -354,6 +354,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", diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index 5f77a2a34e..c5b04a7c7c 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -262,7 +262,16 @@ class MemoriesApi { } /// Performs an HTTP 'GET /memories' operation and returns the [Response]. - Future searchMemoriesWithHttpInfo() async { + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future 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 = {}; final formParams = {}; + 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 = []; @@ -287,8 +309,17 @@ class MemoriesApi { ); } - Future?> searchMemories() async { - final response = await searchMemoriesWithHttpInfo(); + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future?> 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)); } diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart index 7e8d9d51b2..71c60d8e64 100644 --- a/mobile/openapi/lib/model/manual_job_name.dart +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -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 = [ 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'); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index 652c993536..7d50259e24 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -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 toJson() { final json = {}; @@ -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(json, r'id')!, isSaved: mapValueOfType(json, r'isSaved')!, memoryAt: mapDateTime(json, r'memoryAt', r'')!, ownerId: mapValueOfType(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'')!, ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 25d649e195..58bc28a4f9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3129,7 +3129,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": { @@ -9702,7 +9736,9 @@ "enum": [ "person-cleanup", "tag-cleanup", - "user-cleanup" + "user-cleanup", + "memory-cleanup", + "memory-create" ], "type": "string" }, @@ -9859,6 +9895,10 @@ "format": "date-time", "type": "string" }, + "hideAt": { + "format": "date-time", + "type": "string" + }, "id": { "type": "string" }, @@ -9876,6 +9916,10 @@ "format": "date-time", "type": "string" }, + "showAt": { + "format": "date-time", + "type": "string" + }, "type": { "allOf": [ { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0473b5603b..fdfb80c093 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -631,11 +631,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; }; @@ -2194,11 +2196,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 })); } @@ -3506,7 +3518,9 @@ export enum EntityType { 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", diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 710ca9f2f8..1f848ad705 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -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 { - return this.service.search(auth); + searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { + return this.service.search(auth, dto); } @Post() diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 2e10e1aded..9c2ae8c038 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -226,11 +226,13 @@ export interface Memories { createdAt: Generated; data: Json; deletedAt: Timestamp | null; + hideAt: Timestamp | null; id: Generated; isSaved: Generated; memoryAt: Timestamp; ownerId: string; seenAt: Timestamp | null; + showAt: Timestamp | null; type: string; updatedAt: Generated; } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 194bb8ac38..9eef78d4d0 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -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, diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts index c8121dd32e..1f53d7a5c1 100644 --- a/server/src/entities/memory.entity.ts +++ b/server/src/entities/memory.entity.ts @@ -53,6 +53,12 @@ export class MemoryEntity { @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; diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 678b8f701a..b024862ba5 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -14,6 +14,10 @@ export class SystemMetadataEntity }; +export type MemoriesState = { + /** memories have already been created through this date */ + lastOnThisDayDate: string; +}; export interface SystemMetadata extends Record> { [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; @@ -23,4 +27,5 @@ export interface SystemMetadata extends Record; [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.MEMORIES_STATE]: MemoriesState; } diff --git a/server/src/enum.ts b/server/src/enum.ts index 0c1fb01a12..4313900587 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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', diff --git a/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts b/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts new file mode 100644 index 0000000000..d53c7c17f6 --- /dev/null +++ b/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMemoryShowHideDates1739824470990 implements MigrationInterface { + name = 'AddMemoryShowHideDates1739824470990' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "hideAt"`); + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "showAt"`); + } + +} diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 3144f314dd..3b1526f487 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -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 diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 7af363012d..356acf53db 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -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) {} @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(); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 6797ffc396..37e58d5863 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -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 }, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 8e3919a2b1..95ff1ad303 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -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 }, diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 54acfa7baa..e3d85133ac 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -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([]); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index e3aa1f3574..10b8cee2fe 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -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 = {}; + 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)); } diff --git a/server/src/types.ts b/server/src/types.ts index 3a331127e6..ffdbeae77a 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -325,6 +325,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 } @@ -356,7 +360,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; diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index a45274bc1c..8b644e4c31 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -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(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}

- {$memoryLaneTitle(current.memory.yearsAgo)} + {$memoryLaneTitle(current.memory)}

{/if} {/snippet} @@ -352,7 +422,7 @@ {#if current.previousMemory}

{$t('previous').toUpperCase()}

-

{$memoryLaneTitle(current.previousMemory.yearsAgo)}

+

{$memoryLaneTitle(current.previousMemory)}

{/if} @@ -374,17 +444,63 @@ {/key}
- {}} - /> +
+ handleSaveMemory(current)} + class="text-white dark:text-white" + /> + + handleAction('pause')} + direction="left" + align="bottom-right" + class="text-white dark:text-white" + > + handleDeleteMemory(current)} + text={'Remove memory'} + icon={mdiCardsOutline} + /> + handleDeleteMemoryAsset(current)} + text={'Remove photo from this memory'} + icon={mdiImageMinusOutline} + /> + + +
+ +
+ +
{#if current.previous} @@ -449,7 +565,7 @@ {#if current.nextMemory}

{$t('up_next').toUpperCase()}

-

{$memoryLaneTitle(current.nextMemory.yearsAgo)}

+

{$memoryLaneTitle(current.nextMemory)}

{/if} diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 4aa5bc7e9d..c94e7dfaeb 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -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 @@ {/if}
(innerWidth = width)}> - {#each $memoryStore as memory (memory.yearsAgo)} + {#each $memoryStore as memory} {#if memory.assets.length > 0}

- {$memoryLaneTitle(memory.yearsAgo)} + {$memoryLaneTitle(memory)}

+ 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; 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} >
(); +export const memoryStore = writable(); + +export const loadMemories = async () => { + const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) }); + memoryStore.set(memories); +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 4109a7c42f..c87b623549 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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 = (promise: Promise): 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 (fn: () => Promise): Promise<[undefined, T] | [unknown, undefined]> => { diff --git a/web/src/lib/utils/context-menu.ts b/web/src/lib/utils/context-menu.ts index aca1033c7a..461856145e 100644 --- a/web/src/lib/utils/context-menu.ts +++ b/web/src/lib/utils/context-menu.ts @@ -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 }; + } } }; diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index ba22503c70..16236ba135 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -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) => + (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 6ab5cd33be..a3e3d6eb04 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -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);