1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-26 17:21:29 +02:00

feat(server/web): album description (#3558)

* feat(server): add album description

* chore: open api

* fix: tests

* show and edit description on the web

* fix test

* remove unused code

* type event

* format fix

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-08-05 22:43:26 -04:00 committed by GitHub
parent deaf81e2a4
commit 2f26a7edae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 287 additions and 41 deletions

View File

@ -210,6 +210,12 @@ export interface AlbumResponseDto {
* @memberof AlbumResponseDto * @memberof AlbumResponseDto
*/ */
'createdAt': string; 'createdAt': string;
/**
*
* @type {string}
* @memberof AlbumResponseDto
*/
'description': string;
/** /**
* *
* @type {string} * @type {string}
@ -865,6 +871,12 @@ export interface CreateAlbumDto {
* @memberof CreateAlbumDto * @memberof CreateAlbumDto
*/ */
'assetIds'?: Array<string>; 'assetIds'?: Array<string>;
/**
*
* @type {string}
* @memberof CreateAlbumDto
*/
'description'?: string;
/** /**
* *
* @type {Array<string>} * @type {Array<string>}
@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto {
* @memberof UpdateAlbumDto * @memberof UpdateAlbumDto
*/ */
'albumThumbnailAssetId'?: string; 'albumThumbnailAssetId'?: string;
/**
*
* @type {string}
* @memberof UpdateAlbumDto
*/
'description'?: string;
} }
/** /**
* *

View File

@ -13,6 +13,7 @@ Name | Type | Description | Notes
**assetCount** | **int** | | **assetCount** | **int** | |
**assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [default to const []] **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [default to const []]
**createdAt** | [**DateTime**](DateTime.md) | | **createdAt** | [**DateTime**](DateTime.md) | |
**description** | **String** | |
**id** | **String** | | **id** | **String** | |
**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional]
**owner** | [**UserResponseDto**](UserResponseDto.md) | | **owner** | [**UserResponseDto**](UserResponseDto.md) | |

View File

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**albumName** | **String** | | **albumName** | **String** | |
**assetIds** | **List<String>** | | [optional] [default to const []] **assetIds** | **List<String>** | | [optional] [default to const []]
**description** | **String** | | [optional]
**sharedWithUserIds** | **List<String>** | | [optional] [default to const []] **sharedWithUserIds** | **List<String>** | | [optional] [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**albumName** | **String** | | [optional] **albumName** | **String** | | [optional]
**albumThumbnailAssetId** | **String** | | [optional] **albumThumbnailAssetId** | **String** | | [optional]
**description** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -18,6 +18,7 @@ class AlbumResponseDto {
required this.assetCount, required this.assetCount,
this.assets = const [], this.assets = const [],
required this.createdAt, required this.createdAt,
required this.description,
required this.id, required this.id,
this.lastModifiedAssetTimestamp, this.lastModifiedAssetTimestamp,
required this.owner, required this.owner,
@ -37,6 +38,8 @@ class AlbumResponseDto {
DateTime createdAt; DateTime createdAt;
String description;
String id; String id;
/// ///
@ -64,6 +67,7 @@ class AlbumResponseDto {
other.assetCount == assetCount && other.assetCount == assetCount &&
other.assets == assets && other.assets == assets &&
other.createdAt == createdAt && other.createdAt == createdAt &&
other.description == description &&
other.id == id && other.id == id &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.owner == owner && other.owner == owner &&
@ -80,6 +84,7 @@ class AlbumResponseDto {
(assetCount.hashCode) + (assetCount.hashCode) +
(assets.hashCode) + (assets.hashCode) +
(createdAt.hashCode) + (createdAt.hashCode) +
(description.hashCode) +
(id.hashCode) + (id.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(owner.hashCode) + (owner.hashCode) +
@ -89,7 +94,7 @@ class AlbumResponseDto {
(updatedAt.hashCode); (updatedAt.hashCode);
@override @override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]'; String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -102,6 +107,7 @@ class AlbumResponseDto {
json[r'assetCount'] = this.assetCount; json[r'assetCount'] = this.assetCount;
json[r'assets'] = this.assets; json[r'assets'] = this.assets;
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'description'] = this.description;
json[r'id'] = this.id; json[r'id'] = this.id;
if (this.lastModifiedAssetTimestamp != null) { if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
@ -129,6 +135,7 @@ class AlbumResponseDto {
assetCount: mapValueOfType<int>(json, r'assetCount')!, assetCount: mapValueOfType<int>(json, r'assetCount')!,
assets: AssetResponseDto.listFromJson(json[r'assets']), assets: AssetResponseDto.listFromJson(json[r'assets']),
createdAt: mapDateTime(json, r'createdAt', r'')!, createdAt: mapDateTime(json, r'createdAt', r'')!,
description: mapValueOfType<String>(json, r'description')!,
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
owner: UserResponseDto.fromJson(json[r'owner'])!, owner: UserResponseDto.fromJson(json[r'owner'])!,
@ -188,6 +195,7 @@ class AlbumResponseDto {
'assetCount', 'assetCount',
'assets', 'assets',
'createdAt', 'createdAt',
'description',
'id', 'id',
'owner', 'owner',
'ownerId', 'ownerId',

View File

@ -15,6 +15,7 @@ class CreateAlbumDto {
CreateAlbumDto({ CreateAlbumDto({
required this.albumName, required this.albumName,
this.assetIds = const [], this.assetIds = const [],
this.description,
this.sharedWithUserIds = const [], this.sharedWithUserIds = const [],
}); });
@ -22,12 +23,21 @@ class CreateAlbumDto {
List<String> assetIds; List<String> assetIds;
///
/// 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.
///
String? description;
List<String> sharedWithUserIds; List<String> sharedWithUserIds;
@override @override
bool operator ==(Object other) => identical(this, other) || other is CreateAlbumDto && bool operator ==(Object other) => identical(this, other) || other is CreateAlbumDto &&
other.albumName == albumName && other.albumName == albumName &&
other.assetIds == assetIds && other.assetIds == assetIds &&
other.description == description &&
other.sharedWithUserIds == sharedWithUserIds; other.sharedWithUserIds == sharedWithUserIds;
@override @override
@ -35,15 +45,21 @@ class CreateAlbumDto {
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(albumName.hashCode) + (albumName.hashCode) +
(assetIds.hashCode) + (assetIds.hashCode) +
(description == null ? 0 : description!.hashCode) +
(sharedWithUserIds.hashCode); (sharedWithUserIds.hashCode);
@override @override
String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, sharedWithUserIds=$sharedWithUserIds]'; String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, description=$description, sharedWithUserIds=$sharedWithUserIds]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'albumName'] = this.albumName; json[r'albumName'] = this.albumName;
json[r'assetIds'] = this.assetIds; json[r'assetIds'] = this.assetIds;
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'sharedWithUserIds'] = this.sharedWithUserIds; json[r'sharedWithUserIds'] = this.sharedWithUserIds;
return json; return json;
} }
@ -60,6 +76,7 @@ class CreateAlbumDto {
assetIds: json[r'assetIds'] is Iterable assetIds: json[r'assetIds'] is Iterable
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
description: mapValueOfType<String>(json, r'description'),
sharedWithUserIds: json[r'sharedWithUserIds'] is Iterable sharedWithUserIds: json[r'sharedWithUserIds'] is Iterable
? (json[r'sharedWithUserIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'sharedWithUserIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],

View File

@ -15,6 +15,7 @@ class UpdateAlbumDto {
UpdateAlbumDto({ UpdateAlbumDto({
this.albumName, this.albumName,
this.albumThumbnailAssetId, this.albumThumbnailAssetId,
this.description,
}); });
/// ///
@ -33,19 +34,29 @@ class UpdateAlbumDto {
/// ///
String? albumThumbnailAssetId; String? albumThumbnailAssetId;
///
/// 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.
///
String? description;
@override @override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
other.albumName == albumName && other.albumName == albumName &&
other.albumThumbnailAssetId == albumThumbnailAssetId; other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.description == description;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(albumName == null ? 0 : albumName!.hashCode) + (albumName == null ? 0 : albumName!.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode); (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(description == null ? 0 : description!.hashCode);
@override @override
String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId]'; String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -59,6 +70,11 @@ class UpdateAlbumDto {
} else { } else {
// json[r'albumThumbnailAssetId'] = null; // json[r'albumThumbnailAssetId'] = null;
} }
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
return json; return json;
} }
@ -72,6 +88,7 @@ class UpdateAlbumDto {
return UpdateAlbumDto( return UpdateAlbumDto(
albumName: mapValueOfType<String>(json, r'albumName'), albumName: mapValueOfType<String>(json, r'albumName'),
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'), albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
description: mapValueOfType<String>(json, r'description'),
); );
} }
return null; return null;

View File

@ -41,6 +41,11 @@ void main() {
// TODO // TODO
}); });
// String description
test('to test the property `description`', () async {
// TODO
});
// String id // String id
test('to test the property `id`', () async { test('to test the property `id`', () async {
// TODO // TODO

View File

@ -26,6 +26,11 @@ void main() {
// TODO // TODO
}); });
// String description
test('to test the property `description`', () async {
// TODO
});
// List<String> sharedWithUserIds (default value: const []) // List<String> sharedWithUserIds (default value: const [])
test('to test the property `sharedWithUserIds`', () async { test('to test the property `sharedWithUserIds`', () async {
// TODO // TODO

View File

@ -26,6 +26,11 @@ void main() {
// TODO // TODO
}); });
// String description
test('to test the property `description`', () async {
// TODO
});
}); });

View File

@ -4754,6 +4754,9 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"description": {
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -4786,6 +4789,7 @@
"id", "id",
"ownerId", "ownerId",
"albumName", "albumName",
"description",
"createdAt", "createdAt",
"updatedAt", "updatedAt",
"albumThumbnailAssetId", "albumThumbnailAssetId",
@ -5264,6 +5268,9 @@
}, },
"type": "array" "type": "array"
}, },
"description": {
"type": "string"
},
"sharedWithUserIds": { "sharedWithUserIds": {
"items": { "items": {
"format": "uuid", "format": "uuid",
@ -6903,6 +6910,9 @@
"albumThumbnailAssetId": { "albumThumbnailAssetId": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
},
"description": {
"type": "string"
} }
}, },
"type": "object" "type": "object"

View File

@ -7,6 +7,7 @@ export class AlbumResponseDto {
id!: string; id!: string;
ownerId!: string; ownerId!: string;
albumName!: string; albumName!: string;
description!: string;
createdAt!: Date; createdAt!: Date;
updatedAt!: Date; updatedAt!: Date;
albumThumbnailAssetId!: string | null; albumThumbnailAssetId!: string | null;
@ -19,7 +20,7 @@ export class AlbumResponseDto {
lastModifiedAssetTimestamp?: Date; lastModifiedAssetTimestamp?: Date;
} }
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => {
const sharedUsers: UserResponseDto[] = []; const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((user) => { entity.sharedUsers?.forEach((user) => {
@ -29,6 +30,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
return { return {
albumName: entity.albumName, albumName: entity.albumName,
description: entity.description,
albumThumbnailAssetId: entity.albumThumbnailAssetId, albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: entity.createdAt, createdAt: entity.createdAt,
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,
@ -37,33 +39,13 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
owner: mapUser(entity.owner), owner: mapUser(entity.owner),
sharedUsers, sharedUsers,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: entity.assets?.map((asset) => mapAsset(asset)) || [], assets: withAssets ? entity.assets?.map((asset) => mapAsset(asset)) || [] : [],
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
}; };
}
export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((user) => {
const userDto = mapUser(user);
sharedUsers.push(userDto);
});
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
id: entity.id,
ownerId: entity.ownerId,
owner: mapUser(entity.owner),
sharedUsers,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: [],
assetCount: entity.assets?.length || 0,
}; };
}
export const mapAlbum = (entity: AlbumEntity) => _map(entity, true);
export const mapAlbumExcludeAssetInfo = (entity: AlbumEntity) => _map(entity, false);
export class AlbumCountResponseDto { export class AlbumCountResponseDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })

View File

@ -156,6 +156,7 @@ describe(AlbumService.name, () => {
await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({ await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({
albumName: 'Empty album', albumName: 'Empty album',
description: '',
albumThumbnailAssetId: null, albumThumbnailAssetId: null,
assetCount: 0, assetCount: 0,
assets: [], assets: [],

View File

@ -94,6 +94,7 @@ export class AlbumService {
const album = await this.albumRepository.create({ const album = await this.albumRepository.create({
ownerId: authUser.id, ownerId: authUser.id,
albumName: dto.albumName, albumName: dto.albumName,
description: dto.description,
sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [], sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [],
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)), assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
albumThumbnailAssetId: dto.assetIds?.[0] || null, albumThumbnailAssetId: dto.assetIds?.[0] || null,
@ -118,6 +119,7 @@ export class AlbumService {
const updatedAlbum = await this.albumRepository.update({ const updatedAlbum = await this.albumRepository.update({
id: album.id, id: album.id,
albumName: dto.albumName, albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId, albumThumbnailAssetId: dto.albumThumbnailAssetId,
}); });

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ValidateUUID } from '../../domain.util'; import { ValidateUUID } from '../../domain.util';
export class CreateAlbumDto { export class CreateAlbumDto {
@ -8,6 +8,10 @@ export class CreateAlbumDto {
@ApiProperty() @ApiProperty()
albumName!: string; albumName!: string;
@IsString()
@IsOptional()
description?: string;
@ValidateUUID({ optional: true, each: true }) @ValidateUUID({ optional: true, each: true })
sharedWithUserIds?: string[]; sharedWithUserIds?: string[];

View File

@ -1,12 +1,15 @@
import { ApiProperty } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator';
import { IsOptional } from 'class-validator';
import { ValidateUUID } from '../../domain.util'; import { ValidateUUID } from '../../domain.util';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@IsOptional() @IsOptional()
@ApiProperty() @IsString()
albumName?: string; albumName?: string;
@IsOptional()
@IsString()
description?: string;
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true })
albumThumbnailAssetId?: string; albumThumbnailAssetId?: string;
} }

View File

@ -5,8 +5,8 @@ import {
AuthUserDto, AuthUserDto,
BulkIdResponseDto, BulkIdResponseDto,
BulkIdsDto, BulkIdsDto,
CreateAlbumDto, CreateAlbumDto as CreateDto,
UpdateAlbumDto, UpdateAlbumDto as UpdateDto,
} from '@app/domain'; } from '@app/domain';
import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
@ -34,7 +34,7 @@ export class AlbumController {
} }
@Post() @Post()
createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) { createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) {
return this.service.create(authUser, dto); return this.service.create(authUser, dto);
} }
@ -45,7 +45,7 @@ export class AlbumController {
} }
@Patch(':id') @Patch(':id')
updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) { updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto) {
return this.service.update(authUser, id, dto); return this.service.update(authUser, id, dto);
} }

View File

@ -27,6 +27,9 @@ export class AlbumEntity {
@Column({ default: 'Untitled Album' }) @Column({ default: 'Untitled Album' })
albumName!: string; albumName!: string;
@Column({ type: 'text', default: '' })
description!: string;
@CreateDateColumn({ type: 'timestamptz' }) @CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAlbumDescription1691209138541 implements MigrationInterface {
name = 'AddAlbumDescription1691209138541';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" ADD "description" text NOT NULL DEFAULT ''`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "description"`);
}
}

View File

@ -234,7 +234,7 @@ export class TypesenseRepository implements ISearchRepository {
.documents() .documents()
.search({ .search({
q: query, q: query,
query_by: 'albumName', query_by: ['albumName', 'description'].join(','),
filter_by: this.getAlbumFilters(filters), filter_by: this.getAlbumFilters(filters),
}); });

View File

@ -1,11 +1,12 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const albumSchemaVersion = 1; export const albumSchemaVersion = 2;
export const albumSchema: CollectionCreateSchema = { export const albumSchema: CollectionCreateSchema = {
name: `albums-v${albumSchemaVersion}`, name: `albums-v${albumSchemaVersion}`,
fields: [ fields: [
{ name: 'ownerId', type: 'string', facet: false }, { name: 'ownerId', type: 'string', facet: false },
{ name: 'albumName', type: 'string', facet: false, sort: true }, { name: 'albumName', type: 'string', facet: false, sort: true },
{ name: 'description', type: 'string', facet: false },
{ name: 'createdAt', type: 'string', facet: false, sort: true }, { name: 'createdAt', type: 'string', facet: false, sort: true },
{ name: 'updatedAt', type: 'string', facet: false, sort: true }, { name: 'updatedAt', type: 'string', facet: false, sort: true },
], ],

View File

@ -4,7 +4,7 @@ import { SharedLinkType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest'; import request from 'supertest';
import { errorStub } from '../fixtures'; import { errorStub, uuidStub } from '../fixtures';
import { api, db } from '../test-utils'; import { api, db } from '../test-utils';
const user1SharedUser = 'user1SharedUser'; const user1SharedUser = 'user1SharedUser';
@ -193,6 +193,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
updatedAt: expect.any(String), updatedAt: expect.any(String),
ownerId: user1.userId, ownerId: user1.userId,
albumName: 'New album', albumName: 'New album',
description: '',
albumThumbnailAssetId: null, albumThumbnailAssetId: null,
shared: false, shared: false,
sharedUsers: [], sharedUsers: [],
@ -202,4 +203,32 @@ describe(`${AlbumController.name} (e2e)`, () => {
}); });
}); });
}); });
describe('PATCH /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.patch(`/album/${uuidStub.notFound}`)
.send({ albumName: 'New album name' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should update an album', async () => {
const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
const { status, body } = await request(server)
.patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
albumName: 'New album name',
description: 'An album description',
});
expect(status).toBe(200);
expect(body).toEqual({
...album,
updatedAt: expect.any(String),
albumName: 'New album name',
description: 'An album description',
});
});
});
}); });

View File

@ -7,6 +7,7 @@ export const albumStub = {
empty: Object.freeze<AlbumEntity>({ empty: Object.freeze<AlbumEntity>({
id: 'album-1', id: 'album-1',
albumName: 'Empty album', albumName: 'Empty album',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [], assets: [],
@ -20,6 +21,7 @@ export const albumStub = {
sharedWithUser: Object.freeze<AlbumEntity>({ sharedWithUser: Object.freeze<AlbumEntity>({
id: 'album-2', id: 'album-2',
albumName: 'Empty album shared with user', albumName: 'Empty album shared with user',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [], assets: [],
@ -33,6 +35,7 @@ export const albumStub = {
sharedWithMultiple: Object.freeze<AlbumEntity>({ sharedWithMultiple: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
albumName: 'Empty album shared with users', albumName: 'Empty album shared with users',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [], assets: [],
@ -46,6 +49,7 @@ export const albumStub = {
sharedWithAdmin: Object.freeze<AlbumEntity>({ sharedWithAdmin: Object.freeze<AlbumEntity>({
id: 'album-3', id: 'album-3',
albumName: 'Empty album shared with admin', albumName: 'Empty album shared with admin',
description: '',
ownerId: authStub.user1.id, ownerId: authStub.user1.id,
owner: userStub.user1, owner: userStub.user1,
assets: [], assets: [],
@ -59,6 +63,7 @@ export const albumStub = {
oneAsset: Object.freeze<AlbumEntity>({ oneAsset: Object.freeze<AlbumEntity>({
id: 'album-4', id: 'album-4',
albumName: 'Album with one asset', albumName: 'Album with one asset',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [assetStub.image], assets: [assetStub.image],
@ -72,6 +77,7 @@ export const albumStub = {
twoAssets: Object.freeze<AlbumEntity>({ twoAssets: Object.freeze<AlbumEntity>({
id: 'album-4a', id: 'album-4a',
albumName: 'Album with two assets', albumName: 'Album with two assets',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [assetStub.image, assetStub.withLocation], assets: [assetStub.image, assetStub.withLocation],
@ -85,6 +91,7 @@ export const albumStub = {
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({ emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
albumName: 'Empty album with invalid thumbnail', albumName: 'Empty album with invalid thumbnail',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [], assets: [],
@ -98,6 +105,7 @@ export const albumStub = {
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
albumName: 'Empty album with invalid thumbnail', albumName: 'Empty album with invalid thumbnail',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [], assets: [],
@ -111,6 +119,7 @@ export const albumStub = {
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({ oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
albumName: 'Album with one asset and invalid thumbnail', albumName: 'Album with one asset and invalid thumbnail',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [assetStub.image], assets: [assetStub.image],
@ -124,6 +133,7 @@ export const albumStub = {
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({ oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6', id: 'album-6',
albumName: 'Album with one asset and invalid thumbnail', albumName: 'Album with one asset and invalid thumbnail',
description: '',
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
assets: [assetStub.image], assets: [assetStub.image],

View File

@ -68,6 +68,7 @@ const assetResponse: AssetResponseDto = {
const albumResponse: AlbumResponseDto = { const albumResponse: AlbumResponseDto = {
albumName: 'Test Album', albumName: 'Test Album',
description: '',
albumThumbnailAssetId: null, albumThumbnailAssetId: null,
createdAt: today, createdAt: today,
updatedAt: today, updatedAt: today,
@ -146,6 +147,7 @@ export const sharedLinkStub = {
ownerId: authStub.admin.id, ownerId: authStub.admin.id,
owner: userStub.admin, owner: userStub.admin,
albumName: 'Test Album', albumName: 'Test Album',
description: '',
createdAt: today, createdAt: today,
updatedAt: today, updatedAt: today,
albumThumbnailAsset: null, albumThumbnailAsset: null,

View File

@ -210,6 +210,12 @@ export interface AlbumResponseDto {
* @memberof AlbumResponseDto * @memberof AlbumResponseDto
*/ */
'createdAt': string; 'createdAt': string;
/**
*
* @type {string}
* @memberof AlbumResponseDto
*/
'description': string;
/** /**
* *
* @type {string} * @type {string}
@ -865,6 +871,12 @@ export interface CreateAlbumDto {
* @memberof CreateAlbumDto * @memberof CreateAlbumDto
*/ */
'assetIds'?: Array<string>; 'assetIds'?: Array<string>;
/**
*
* @type {string}
* @memberof CreateAlbumDto
*/
'description'?: string;
/** /**
* *
* @type {Array<string>} * @type {Array<string>}
@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto {
* @memberof UpdateAlbumDto * @memberof UpdateAlbumDto
*/ */
'albumThumbnailAssetId'?: string; 'albumThumbnailAssetId'?: string;
/**
*
* @type {string}
* @memberof UpdateAlbumDto
*/
'description'?: string;
} }
/** /**
* *

View File

@ -44,6 +44,7 @@
import { handleError } from '../../utils/handle-error'; import { handleError } from '../../utils/handle-error';
import { downloadArchive } from '../../utils/asset-utils'; import { downloadArchive } from '../../utils/asset-utils';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import EditDescriptionModal from './edit-description-modal.svelte';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined; export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@ -73,6 +74,7 @@
let isShowAlbumOptions = false; let isShowAlbumOptions = false;
let isShowThumbnailSelection = false; let isShowThumbnailSelection = false;
let isShowDeleteConfirmation = false; let isShowDeleteConfirmation = false;
let isEditingDescription = false;
let backUrl = '/albums'; let backUrl = '/albums';
let currentAlbumName = ''; let currentAlbumName = '';
@ -298,6 +300,27 @@
const handleSelectAll = () => { const handleSelectAll = () => {
multiSelectAsset = new Set(album.assets); multiSelectAsset = new Set(album.assets);
}; };
const descriptionUpdatedHandler = (description: string) => {
try {
api.albumApi.updateAlbumInfo({
id: album.id,
updateAlbumDto: {
description,
},
});
album.description = description;
} catch (e) {
console.error('Error [descriptionUpdatedHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error setting album description, check console for more details',
});
}
isEditingDescription = false;
};
</script> </script>
<section class="bg-immich-bg dark:bg-immich-dark-bg" class:hidden={isShowThumbnailSelection}> <section class="bg-immich-bg dark:bg-immich-dark-bg" class:hidden={isShowThumbnailSelection}>
@ -405,6 +428,7 @@
{/if} {/if}
<section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40"> <section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40">
<!-- ALBUM TITLE -->
<input <input
on:keydown={(e) => { on:keydown={(e) => {
if (e.key == 'Enter') { if (e.key == 'Enter') {
@ -421,8 +445,10 @@
bind:value={album.albumName} bind:value={album.albumName}
disabled={!isOwned} disabled={!isOwned}
bind:this={titleInput} bind:this={titleInput}
title="Edit Title"
/> />
<!-- ALBUM SUMMARY -->
{#if album.assetCount > 0} {#if album.assetCount > 0}
<span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> <span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p class="">{getDateRange()}</p> <p class="">{getDateRange()}</p>
@ -448,6 +474,17 @@
</div> </div>
{/if} {/if}
<!-- ALBUM DESCRIPTION -->
<button
class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300"
on:click={() => (isEditingDescription = true)}
class:hover:border-gray-400={isOwned}
disabled={!isOwned}
title="Edit description"
>
{album.description || 'Add description'}
</button>
{#if album.assetCount > 0 && !isShowAssetSelection} {#if album.assetCount > 0 && !isShowAssetSelection}
<GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} /> <GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} />
{:else} {:else}
@ -490,6 +527,7 @@
{#if isShowShareLinkModal} {#if isShowShareLinkModal}
<CreateSharedLinkModal on:close={() => (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} /> <CreateSharedLinkModal on:close={() => (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} />
{/if} {/if}
{#if isShowShareInfoModal} {#if isShowShareInfoModal}
<ShareInfoModal on:close={() => (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} /> <ShareInfoModal on:close={() => (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} />
{/if} {/if}
@ -515,3 +553,11 @@
</svelte:fragment> </svelte:fragment>
</ConfirmDialogue> </ConfirmDialogue>
{/if} {/if}
{#if isEditingDescription}
<EditDescriptionModal
{album}
on:close={() => (isEditingDescription = false)}
on:updated={({ detail: description }) => descriptionUpdatedHandler(description)}
/>
{/if}

View File

@ -0,0 +1,43 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { AlbumResponseDto } from '@api';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import Button from '../elements/buttons/button.svelte';
const dispatch = createEventDispatcher<{
close: void;
updated: string;
}>();
export let album: AlbumResponseDto;
let description = album.description;
const handleSave = () => {
dispatch('updated', description);
};
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit description</h1>
</div>
<form on:submit|preventDefault={handleSave} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Description</label>
<!-- svelte-ignore a11y-autofocus -->
<input class="immich-form-input" id="name" name="name" type="text" bind:value={description} autofocus />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
<Button type="submit" fullwidth>Ok</Button>
</div>
</form>
</div>
</FullScreenModal>

View File

@ -5,6 +5,7 @@ import { userFactory } from './user-factory';
export const albumFactory = Sync.makeFactory<AlbumResponseDto>({ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
albumName: Sync.each(() => faker.commerce.product()), albumName: Sync.each(() => faker.commerce.product()),
description: '',
albumThumbnailAssetId: null, albumThumbnailAssetId: null,
assetCount: Sync.each((i) => i % 5), assetCount: Sync.each((i) => i % 5),
assets: [], assets: [],