feat: persistent memories (#15953)

feat: memories

refactor

chore: use heart as favorite icon

fix: linting
This commit is contained in:
Jason Rasmussen 2025-02-21 13:31:37 -05:00 committed by GitHub
parent 502f6e020d
commit d350022dec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 585 additions and 70 deletions

View File

@ -352,6 +352,8 @@
"version_check_enabled_description": "Enable version check", "version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with github.com", "version_check_implications": "The version check feature relies on periodic communication with github.com",
"version_check_settings": "Version Check", "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", "version_check_settings_description": "Enable/disable the new version notification",
"video_conversion_job": "Transcode videos", "video_conversion_job": "Transcode videos",
"video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices" "video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices"
@ -1076,6 +1078,8 @@
"remove_url": "Remove URL", "remove_url": "Remove URL",
"remove_user": "Remove user", "remove_user": "Remove user",
"removed_api_key": "Removed API Key: {name}", "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_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites", "removed_from_favorites": "Removed from favorites",
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",

View File

@ -262,7 +262,16 @@ class MemoriesApi {
} }
/// Performs an HTTP 'GET /memories' operation and returns the [Response]. /// 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 // ignore: prefer_const_declarations
final path = r'/memories'; final path = r'/memories';
@ -273,6 +282,19 @@ class MemoriesApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <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>[]; const contentTypes = <String>[];
@ -287,8 +309,17 @@ class MemoriesApi {
); );
} }
Future<List<MemoryResponseDto>?> searchMemories() async { /// Parameters:
final response = await searchMemoriesWithHttpInfo(); ///
/// * [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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View File

@ -26,12 +26,16 @@ class ManualJobName {
static const personCleanup = ManualJobName._(r'person-cleanup'); static const personCleanup = ManualJobName._(r'person-cleanup');
static const tagCleanup = ManualJobName._(r'tag-cleanup'); static const tagCleanup = ManualJobName._(r'tag-cleanup');
static const userCleanup = ManualJobName._(r'user-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]. /// List of all possible values in this [enum][ManualJobName].
static const values = <ManualJobName>[ static const values = <ManualJobName>[
personCleanup, personCleanup,
tagCleanup, tagCleanup,
userCleanup, userCleanup,
memoryCleanup,
memoryCreate,
]; ];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
@ -73,6 +77,8 @@ class ManualJobNameTypeTransformer {
case r'person-cleanup': return ManualJobName.personCleanup; case r'person-cleanup': return ManualJobName.personCleanup;
case r'tag-cleanup': return ManualJobName.tagCleanup; case r'tag-cleanup': return ManualJobName.tagCleanup;
case r'user-cleanup': return ManualJobName.userCleanup; case r'user-cleanup': return ManualJobName.userCleanup;
case r'memory-cleanup': return ManualJobName.memoryCleanup;
case r'memory-create': return ManualJobName.memoryCreate;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -17,11 +17,13 @@ class MemoryResponseDto {
required this.createdAt, required this.createdAt,
required this.data, required this.data,
this.deletedAt, this.deletedAt,
this.hideAt,
required this.id, required this.id,
required this.isSaved, required this.isSaved,
required this.memoryAt, required this.memoryAt,
required this.ownerId, required this.ownerId,
this.seenAt, this.seenAt,
this.showAt,
required this.type, required this.type,
required this.updatedAt, required this.updatedAt,
}); });
@ -40,6 +42,14 @@ class MemoryResponseDto {
/// ///
DateTime? deletedAt; 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; String id;
bool isSaved; bool isSaved;
@ -56,6 +66,14 @@ class MemoryResponseDto {
/// ///
DateTime? seenAt; 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; MemoryType type;
DateTime updatedAt; DateTime updatedAt;
@ -66,11 +84,13 @@ class MemoryResponseDto {
other.createdAt == createdAt && other.createdAt == createdAt &&
other.data == data && other.data == data &&
other.deletedAt == deletedAt && other.deletedAt == deletedAt &&
other.hideAt == hideAt &&
other.id == id && other.id == id &&
other.isSaved == isSaved && other.isSaved == isSaved &&
other.memoryAt == memoryAt && other.memoryAt == memoryAt &&
other.ownerId == ownerId && other.ownerId == ownerId &&
other.seenAt == seenAt && other.seenAt == seenAt &&
other.showAt == showAt &&
other.type == type && other.type == type &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt;
@ -81,16 +101,18 @@ class MemoryResponseDto {
(createdAt.hashCode) + (createdAt.hashCode) +
(data.hashCode) + (data.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) +
(hideAt == null ? 0 : hideAt!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isSaved.hashCode) + (isSaved.hashCode) +
(memoryAt.hashCode) + (memoryAt.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(seenAt == null ? 0 : seenAt!.hashCode) + (seenAt == null ? 0 : seenAt!.hashCode) +
(showAt == null ? 0 : showAt!.hashCode) +
(type.hashCode) + (type.hashCode) +
(updatedAt.hashCode); (updatedAt.hashCode);
@override @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() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -101,6 +123,11 @@ class MemoryResponseDto {
json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
} else { } else {
// json[r'deletedAt'] = null; // 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'id'] = this.id;
json[r'isSaved'] = this.isSaved; json[r'isSaved'] = this.isSaved;
@ -110,6 +137,11 @@ class MemoryResponseDto {
json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String();
} else { } else {
// json[r'seenAt'] = null; // 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'type'] = this.type;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
@ -129,11 +161,13 @@ class MemoryResponseDto {
createdAt: mapDateTime(json, r'createdAt', r'')!, createdAt: mapDateTime(json, r'createdAt', r'')!,
data: OnThisDayDto.fromJson(json[r'data'])!, data: OnThisDayDto.fromJson(json[r'data'])!,
deletedAt: mapDateTime(json, r'deletedAt', r''), deletedAt: mapDateTime(json, r'deletedAt', r''),
hideAt: mapDateTime(json, r'hideAt', r''),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isSaved: mapValueOfType<bool>(json, r'isSaved')!, isSaved: mapValueOfType<bool>(json, r'isSaved')!,
memoryAt: mapDateTime(json, r'memoryAt', r'')!, memoryAt: mapDateTime(json, r'memoryAt', r'')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
seenAt: mapDateTime(json, r'seenAt', r''), seenAt: mapDateTime(json, r'seenAt', r''),
showAt: mapDateTime(json, r'showAt', r''),
type: MemoryType.fromJson(json[r'type'])!, type: MemoryType.fromJson(json[r'type'])!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!,
); );

View File

@ -3146,7 +3146,41 @@
"/memories": { "/memories": {
"get": { "get": {
"operationId": "searchMemories", "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": { "responses": {
"200": { "200": {
"content": { "content": {
@ -9882,7 +9916,9 @@
"enum": [ "enum": [
"person-cleanup", "person-cleanup",
"tag-cleanup", "tag-cleanup",
"user-cleanup" "user-cleanup",
"memory-cleanup",
"memory-create"
], ],
"type": "string" "type": "string"
}, },
@ -10039,6 +10075,10 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"hideAt": {
"format": "date-time",
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -10056,6 +10096,10 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"showAt": {
"format": "date-time",
"type": "string"
},
"type": { "type": {
"allOf": [ "allOf": [
{ {

View File

@ -640,11 +640,13 @@ export type MemoryResponseDto = {
createdAt: string; createdAt: string;
data: OnThisDayDto; data: OnThisDayDto;
deletedAt?: string; deletedAt?: string;
hideAt?: string;
id: string; id: string;
isSaved: boolean; isSaved: boolean;
memoryAt: string; memoryAt: string;
ownerId: string; ownerId: string;
seenAt?: string; seenAt?: string;
showAt?: string;
"type": MemoryType; "type": MemoryType;
updatedAt: string; updatedAt: string;
}; };
@ -2222,11 +2224,21 @@ export function reverseGeocode({ lat, lon }: {
...opts ...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<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: MemoryResponseDto[]; data: MemoryResponseDto[];
}>("/memories", { }>(`/memories${QS.query(QS.explode({
"for": $for,
isSaved,
isTrashed,
"type": $type
}))}`, {
...opts ...opts
})); }));
} }
@ -3565,7 +3577,9 @@ export enum AssetMediaSize {
export enum ManualJobName { export enum ManualJobName {
PersonCleanup = "person-cleanup", PersonCleanup = "person-cleanup",
TagCleanup = "tag-cleanup", TagCleanup = "tag-cleanup",
UserCleanup = "user-cleanup" UserCleanup = "user-cleanup",
MemoryCleanup = "memory-cleanup",
MemoryCreate = "memory-create"
} }
export enum JobName { export enum JobName {
ThumbnailGeneration = "thumbnailGeneration", ThumbnailGeneration = "thumbnailGeneration",

View File

@ -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 { ApiTags } from '@nestjs/swagger';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
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 { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MemoryService } from 'src/services/memory.service'; import { MemoryService } from 'src/services/memory.service';
@ -15,8 +15,8 @@ export class MemoryController {
@Get() @Get()
@Authenticated({ permission: Permission.MEMORY_READ }) @Authenticated({ permission: Permission.MEMORY_READ })
searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> { searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth); return this.service.search(auth, dto);
} }
@Post() @Post()

2
server/src/db.d.ts vendored
View File

@ -227,11 +227,13 @@ export interface Memories {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
data: Json; data: Json;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
hideAt: Timestamp | null;
id: Generated<string>; id: Generated<string>;
isSaved: Generated<boolean>; isSaved: Generated<boolean>;
memoryAt: Timestamp; memoryAt: Timestamp;
ownerId: string; ownerId: string;
seenAt: Timestamp | null; seenAt: Timestamp | null;
showAt: Timestamp | null;
type: string; type: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
} }

View File

@ -5,7 +5,7 @@ import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryType } from 'src/enum'; import { MemoryType } from 'src/enum';
import { MemoryItem } from 'src/types'; import { MemoryItem } from 'src/types';
import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
class MemoryBaseDto { class MemoryBaseDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
@ -15,6 +15,22 @@ class MemoryBaseDto {
seenAt?: Date; 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 { class OnThisDayDto {
@IsInt() @IsInt()
@IsPositive() @IsPositive()
@ -62,6 +78,8 @@ export class MemoryResponseDto {
deletedAt?: Date; deletedAt?: Date;
memoryAt!: Date; memoryAt!: Date;
seenAt?: Date; seenAt?: Date;
showAt?: Date;
hideAt?: Date;
ownerId!: string; ownerId!: string;
@ApiProperty({ enumName: 'MemoryType', enum: MemoryType }) @ApiProperty({ enumName: 'MemoryType', enum: MemoryType })
type!: MemoryType; type!: MemoryType;
@ -78,6 +96,8 @@ export const mapMemory = (entity: MemoryItem): MemoryResponseDto => {
deletedAt: entity.deletedAt ?? undefined, deletedAt: entity.deletedAt ?? undefined,
memoryAt: entity.memoryAt, memoryAt: entity.memoryAt,
seenAt: entity.seenAt ?? undefined, seenAt: entity.seenAt ?? undefined,
showAt: entity.showAt ?? undefined,
hideAt: entity.hideAt ?? undefined,
ownerId: entity.ownerId, ownerId: entity.ownerId,
type: entity.type as MemoryType, type: entity.type as MemoryType,
data: entity.data as unknown as MemoryData, data: entity.data as unknown as MemoryData,

View File

@ -53,6 +53,12 @@ export class MemoryEntity<T extends MemoryType = MemoryType> {
@Column({ type: 'timestamptz' }) @Column({ type: 'timestamptz' })
memoryAt!: Date; memoryAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
showAt?: Date;
@Column({ type: 'timestamptz', nullable: true })
hideAt?: Date;
/** when the user last viewed the memory */ /** when the user last viewed the memory */
@Column({ type: 'timestamptz', nullable: true }) @Column({ type: 'timestamptz', nullable: true })
seenAt?: Date; seenAt?: Date;

View File

@ -14,6 +14,10 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> }; 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>> { export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
@ -23,4 +27,5 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>; [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>; [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
[SystemMetadataKey.MEMORIES_STATE]: MemoriesState;
} }

View File

@ -187,6 +187,7 @@ export enum StorageFolder {
export enum SystemMetadataKey { export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
FACIAL_RECOGNITION_STATE = 'facial-recognition-state', FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
MEMORIES_STATE = 'memories-state',
ADMIN_ONBOARDING = 'admin-onboarding', ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config', SYSTEM_CONFIG = 'system-config',
SYSTEM_FLAGS = 'system-flags', SYSTEM_FLAGS = 'system-flags',
@ -233,6 +234,8 @@ export enum ManualJobName {
PERSON_CLEANUP = 'person-cleanup', PERSON_CLEANUP = 'person-cleanup',
TAG_CLEANUP = 'tag-cleanup', TAG_CLEANUP = 'tag-cleanup',
USER_CLEANUP = 'user-cleanup', USER_CLEANUP = 'user-cleanup',
MEMORY_CLEANUP = 'memory-cleanup',
MEMORY_CREATE = 'memory-create',
} }
export enum AssetPathType { export enum AssetPathType {
@ -477,6 +480,10 @@ export enum JobName {
CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs',
CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens',
// memories
MEMORIES_CLEANUP = 'memories-cleanup',
MEMORIES_CREATE = 'memories-create',
// smart search // smart search
QUEUE_SMART_SEARCH = 'queue-smart-search', QUEUE_SMART_SEARCH = 'queue-smart-search',
SMART_SEARCH = 'smart-search', SMART_SEARCH = 'smart-search',

View File

@ -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"`);
}
}

View File

@ -1,12 +1,68 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- MemoryRepository.cleanup
delete from "memories"
where
"createdAt" < $1
and "isSaved" = $2
-- MemoryRepository.search -- MemoryRepository.search
select 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 from
"memories" "memories"
where 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 order by
"memoryAt" desc "memoryAt" desc

View File

@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely'; import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB, Memories } from 'src/db'; import { DB, Memories } from 'src/db';
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { MemorySearchDto } from 'src/dtos/memory.dto';
import { IBulkAsset } from 'src/types'; import { IBulkAsset } from 'src/types';
@Injectable() @Injectable()
@ -11,10 +13,40 @@ export class MemoryRepository implements IBulkAsset {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] }) @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 return this.db
.selectFrom('memories') .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) .where('ownerId', '=', ownerId)
.orderBy('memoryAt', 'desc') .orderBy('memoryAt', 'desc')
.execute(); .execute();

View File

@ -40,6 +40,8 @@ describe(JobService.name, () => {
{ name: JobName.ASSET_DELETION_CHECK }, { name: JobName.ASSET_DELETION_CHECK },
{ name: JobName.USER_DELETE_CHECK }, { name: JobName.USER_DELETE_CHECK },
{ name: JobName.PERSON_CLEANUP }, { name: JobName.PERSON_CLEANUP },
{ name: JobName.MEMORIES_CLEANUP },
{ name: JobName.MEMORIES_CREATE },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE }, { name: JobName.USER_SYNC_USAGE },

View File

@ -31,6 +31,14 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
return { name: JobName.USER_DELETE_CHECK }; 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: { default: {
throw new BadRequestException('Invalid job name'); throw new BadRequestException('Invalid job name');
} }
@ -207,6 +215,8 @@ export class JobService extends BaseService {
{ name: JobName.ASSET_DELETION_CHECK }, { name: JobName.ASSET_DELETION_CHECK },
{ name: JobName.USER_DELETE_CHECK }, { name: JobName.USER_DELETE_CHECK },
{ name: JobName.PERSON_CLEANUP }, { name: JobName.PERSON_CLEANUP },
{ name: JobName.MEMORIES_CLEANUP },
{ name: JobName.MEMORIES_CREATE },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE }, { name: JobName.USER_SYNC_USAGE },

View File

@ -21,7 +21,7 @@ describe(MemoryService.name, () => {
describe('search', () => { describe('search', () => {
it('should search memories', async () => { it('should search memories', async () => {
mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); 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.arrayContaining([
expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }),
expect.objectContaining({ id: 'memoryEmpty', assets: [] }), expect.objectContaining({ id: 'memoryEmpty', assets: [] }),
@ -30,7 +30,7 @@ describe(MemoryService.name, () => {
}); });
it('should map ', async () => { it('should map ', async () => {
await expect(sut.search(authStub.admin)).resolves.toEqual([]); await expect(sut.search(authStub.admin, {})).resolves.toEqual([]);
}); });
}); });

View File

@ -1,16 +1,84 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { JsonObject } from 'src/db'; import { JsonObject } from 'src/db';
import { OnJob } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
import { Permission } from 'src/enum'; import { OnThisDayData } from 'src/entities/memory.entity';
import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; 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() @Injectable()
export class MemoryService extends BaseService { export class MemoryService extends BaseService {
async search(auth: AuthDto) { @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK })
const memories = await this.memoryRepository.search(auth.user.id); 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)); return memories.map((memory) => mapMemory(memory));
} }

View File

@ -326,6 +326,10 @@ export type JobItem =
| { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob }
| { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob }
// Memories
| { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob }
| { name: JobName.MEMORIES_CREATE; data?: IBaseJob }
// Filesystem // Filesystem
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob } | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
@ -357,7 +361,11 @@ export type JobItem =
| { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob }
// Version check // 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; export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS;

View File

@ -12,5 +12,6 @@ export const newMemoryRepositoryMock = (): Mocked<RepositoryInterface<MemoryRepo
getAssetIds: vitest.fn().mockResolvedValue(new Set()), getAssetIds: vitest.fn().mockResolvedValue(new Set()),
addAssetIds: vitest.fn(), addAssetIds: vitest.fn(),
removeAssetIds: vitest.fn(), removeAssetIds: vitest.fn(),
cleanup: vitest.fn(),
}; };
}; };

View File

@ -13,25 +13,45 @@
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-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 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 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 ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.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 { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type Viewport } from '$lib/stores/assets.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 { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util'; import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { AssetMediaSize, getMemoryLane, type AssetResponseDto, type MemoryLaneResponseDto } from '@immich/sdk';
import { import {
AssetMediaSize,
deleteMemory,
removeMemoryAssets,
updateMemory,
type AssetResponseDto,
type MemoryResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import {
mdiCardsOutline,
mdiChevronDown, mdiChevronDown,
mdiChevronLeft, mdiChevronLeft,
mdiChevronRight, mdiChevronRight,
mdiChevronUp, mdiChevronUp,
mdiDotsVertical, mdiDotsVertical,
mdiHeart,
mdiHeartOutline,
mdiImageMinusOutline,
mdiImageSearch, mdiImageSearch,
mdiPause, mdiPause,
mdiPlay, mdiPlay,
@ -45,9 +65,6 @@
import { tweened } from 'svelte/motion'; import { tweened } from 'svelte/motion';
import { derived as storeDerived } from 'svelte/store'; import { derived as storeDerived } from 'svelte/store';
import { fade } from 'svelte/transition'; 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 = { type MemoryIndex = {
memoryIndex: number; memoryIndex: number;
@ -55,20 +72,20 @@
}; };
type MemoryAsset = MemoryIndex & { type MemoryAsset = MemoryIndex & {
memory: MemoryLaneResponseDto; memory: MemoryResponseDto;
asset: AssetResponseDto; asset: AssetResponseDto;
previousMemory?: MemoryLaneResponseDto; previousMemory?: MemoryResponseDto;
previous?: MemoryAsset; previous?: MemoryAsset;
next?: MemoryAsset; next?: MemoryAsset;
nextMemory?: MemoryLaneResponseDto; nextMemory?: MemoryResponseDto;
}; };
let memoryGallery: HTMLElement | undefined = $state(); let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state(); let memoryWrapper: HTMLElement | undefined = $state();
let galleryInView = $state(false); let galleryInView = $state(false);
let paused = $state(false); let paused = $state(false);
let current: MemoryAsset | undefined = $state(undefined); let current = $state<MemoryAsset | undefined>(undefined);
// let memories: MemoryAsset[] = []; let isSaved = $derived(current?.memory.isSaved);
let resetPromise = $state(Promise.resolve()); let resetPromise = $state(Promise.resolve());
const { isViewing } = assetViewingStore; const { isViewing } = assetViewingStore;
@ -168,6 +185,7 @@
} }
current.memory.assets = current.memory.assets; current.memory.assets = current.memory.assets;
}; };
const handleRemove = (ids: string[]) => { const handleRemove = (ids: string[]) => {
if (!current) { if (!current) {
return; return;
@ -186,13 +204,65 @@
current = loadFromParams($memories, $page); 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 () => { onMount(async () => {
if (!$memoryStore) { if (!$memoryStore) {
const localTime = new Date(); await loadMemories();
$memoryStore = await getMemoryLane({
month: localTime.getMonth() + 1,
day: localTime.getDate(),
});
} }
init(); init();
@ -268,7 +338,7 @@
{#snippet leading()} {#snippet leading()}
{#if current} {#if current}
<p class="text-lg"> <p class="text-lg">
{$memoryLaneTitle(current.memory.yearsAgo)} {$memoryLaneTitle(current.memory)}
</p> </p>
{/if} {/if}
{/snippet} {/snippet}
@ -352,7 +422,7 @@
{#if current.previousMemory} {#if current.previousMemory}
<div class="absolute bottom-4 right-4 text-left text-white"> <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-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> </div>
{/if} {/if}
</button> </button>
@ -374,17 +444,63 @@
{/key} {/key}
<div <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-0={galleryInView}
class:opacity-100={!galleryInView} class:opacity-100={!galleryInView}
> >
<CircleIconButton <div class="flex">
href="{AppRoute.PHOTOS}?at={current.asset.id}" <IconButton
icon={mdiImageSearch} icon={isSaved ? mdiHeart : mdiHeartOutline}
title={$t('view_in_timeline')} shape="round"
color="light" variant="ghost"
onclick={() => {}} 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}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
size="giant"
class="text-white dark:text-white"
/>
</div>
</div> </div>
<!-- CONTROL BUTTONS --> <!-- CONTROL BUTTONS -->
{#if current.previous} {#if current.previous}
@ -449,7 +565,7 @@
{#if current.nextMemory} {#if current.nextMemory}
<div class="absolute bottom-4 left-4 text-left text-white"> <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-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> </div>
{/if} {/if}
</button> </button>

View File

@ -2,20 +2,18 @@
import { resizeObserver } from '$lib/actions/resize-observer'; import { resizeObserver } from '$lib/actions/resize-observer';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; 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 { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { getMemoryLane } from '@immich/sdk';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let shouldRender = $derived($memoryStore?.length > 0); let shouldRender = $derived($memoryStore?.length > 0);
onMount(async () => { onMount(async () => {
const localTime = new Date(); await loadMemories();
$memoryStore = await getMemoryLane({ month: localTime.getMonth() + 1, day: localTime.getDate() });
}); });
let memoryLaneElement: HTMLElement | undefined = $state(); let memoryLaneElement: HTMLElement | undefined = $state();
@ -71,7 +69,7 @@
</div> </div>
{/if} {/if}
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}> <div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each $memoryStore as memory (memory.yearsAgo)} {#each $memoryStore as memory}
{#if memory.assets.length > 0} {#if memory.assets.length > 0}
<a <a
class="memory-card relative mr-8 inline-block aspect-[3/4] md:aspect-video h-[215px] rounded-xl" class="memory-card relative mr-8 inline-block aspect-[3/4] md:aspect-video h-[215px] rounded-xl"
@ -84,7 +82,7 @@
draggable="false" draggable="false"
/> />
<p class="absolute bottom-2 left-4 z-10 text-lg text-white"> <p class="absolute bottom-2 left-4 z-10 text-lg text-white">
{$memoryLaneTitle(memory.yearsAgo)} {$memoryLaneTitle(memory)}
</p> </p>
<div <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" 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"

View File

@ -1,22 +1,23 @@
<script lang="ts"> <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, { import CircleIconButton, {
type Color, type Color,
type Padding, type Padding,
} from '$lib/components/elements/buttons/circle-icon-button.svelte'; } from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { import {
getContextMenuPositionFromBoundingRect, getContextMenuPositionFromBoundingRect,
getContextMenuPositionFromEvent, getContextMenuPositionFromEvent,
type Align, type Align,
} from '$lib/utils/context-menu'; } from '$lib/utils/context-menu';
import { generateId } from '$lib/utils/generate-id'; 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 { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Props { type Props = {
icon: string; icon: string;
title: string; title: string;
/** /**
@ -36,7 +37,7 @@
buttonClass?: string | undefined; buttonClass?: string | undefined;
hideContent?: boolean; hideContent?: boolean;
children?: Snippet; children?: Snippet;
} } & HTMLAttributes<HTMLDivElement>;
let { let {
icon, icon,
@ -49,6 +50,7 @@
buttonClass = undefined, buttonClass = undefined,
hideContent = false, hideContent = false,
children, children,
...restProps
}: Props = $props(); }: Props = $props();
let isOpen = $state(false); let isOpen = $state(false);
@ -129,6 +131,7 @@
}} }}
use:clickOutside={{ onOutclick: closeDropdown }} use:clickOutside={{ onOutclick: closeDropdown }}
onresize={onResize} onresize={onResize}
{...restProps}
> >
<div bind:this={buttonContainer}> <div bind:this={buttonContainer}>
<CircleIconButton <CircleIconButton

View File

@ -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'; 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);
};

View File

@ -6,6 +6,7 @@ import {
AssetJobName, AssetJobName,
AssetMediaSize, AssetMediaSize,
JobName, JobName,
MemoryType,
finishOAuth, finishOAuth,
getAssetOriginalPath, getAssetOriginalPath,
getAssetPlaybackPath, getAssetPlaybackPath,
@ -16,6 +17,7 @@ import {
linkOAuthAccount, linkOAuthAccount,
startOAuth, startOAuth,
unlinkOAuthAccount, unlinkOAuthAccount,
type MemoryResponseDto,
type PersonResponseDto, type PersonResponseDto,
type SharedLinkResponseDto, type SharedLinkResponseDto,
type UserResponseDto, type UserResponseDto,
@ -320,7 +322,14 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
}; };
export const memoryLaneTitle = derived(t, ($t) => { 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]> => { export const withError = async <T>(fn: () => Promise<T>): Promise<[undefined, T] | [unknown, undefined]> => {

View File

@ -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 }; export type ContextMenuPosition = { x: number; y: number };
@ -28,5 +28,11 @@ export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Ali
case 'top-right': { case 'top-right': {
return { x: rect.x + rect.width, y: rect.y }; 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 };
}
} }
}; };

View File

@ -77,3 +77,11 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string
return ''; 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();

View File

@ -44,6 +44,8 @@
{ title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup }, { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup },
{ title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup }, { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup },
{ title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, { 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 })); ].map(({ value, title }) => ({ id: value, label: title, value }));
const handleCancel = () => (isOpen = false); const handleCancel = () => (isOpen = false);