mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat: persistent memories (#15953)
feat: memories refactor chore: use heart as favorite icon fix: linting
This commit is contained in:
parent
502f6e020d
commit
d350022dec
@ -352,6 +352,8 @@
|
|||||||
"version_check_enabled_description": "Enable version check",
|
"version_check_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",
|
||||||
|
37
mobile/openapi/lib/api/memories_api.dart
generated
37
mobile/openapi/lib/api/memories_api.dart
generated
@ -262,7 +262,16 @@ class MemoriesApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /memories' operation and returns the [Response].
|
/// 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));
|
||||||
}
|
}
|
||||||
|
6
mobile/openapi/lib/model/manual_job_name.dart
generated
6
mobile/openapi/lib/model/manual_job_name.dart
generated
@ -26,12 +26,16 @@ class ManualJobName {
|
|||||||
static const personCleanup = ManualJobName._(r'person-cleanup');
|
static const 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');
|
||||||
|
36
mobile/openapi/lib/model/memory_response_dto.dart
generated
36
mobile/openapi/lib/model/memory_response_dto.dart
generated
@ -17,11 +17,13 @@ class MemoryResponseDto {
|
|||||||
required this.createdAt,
|
required this.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'')!,
|
||||||
);
|
);
|
||||||
|
@ -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": [
|
||||||
{
|
{
|
||||||
|
@ -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",
|
||||||
|
@ -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
2
server/src/db.d.ts
vendored
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddMemoryShowHideDates1739824470990 implements MigrationInterface {
|
||||||
|
name = 'AddMemoryShowHideDates1739824470990'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "memories" ADD "showAt" TIMESTAMP WITH TIME ZONE`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "memories" ADD "hideAt" TIMESTAMP WITH TIME ZONE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "hideAt"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "showAt"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,12 +1,68 @@
|
|||||||
-- NOTE: This file is auto generated by ./sql-generator
|
-- 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
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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 },
|
||||||
|
@ -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 },
|
||||||
|
@ -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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
|
};
|
||||||
|
@ -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]> => {
|
||||||
|
@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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();
|
||||||
|
@ -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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user