diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index ba920d6d13..4476442134 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -210,6 +210,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'createdAt': string; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'description': string; /** * * @type {string} @@ -865,6 +871,12 @@ export interface CreateAlbumDto { * @memberof CreateAlbumDto */ 'assetIds'?: Array; + /** + * + * @type {string} + * @memberof CreateAlbumDto + */ + 'description'?: string; /** * * @type {Array} @@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'albumThumbnailAssetId'?: string; + /** + * + * @type {string} + * @memberof UpdateAlbumDto + */ + 'description'?: string; } /** * diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index 0f2f203819..9a806a9588 100644 Binary files a/mobile/openapi/doc/AlbumResponseDto.md and b/mobile/openapi/doc/AlbumResponseDto.md differ diff --git a/mobile/openapi/doc/CreateAlbumDto.md b/mobile/openapi/doc/CreateAlbumDto.md index 557a2499ba..0a472725e4 100644 Binary files a/mobile/openapi/doc/CreateAlbumDto.md and b/mobile/openapi/doc/CreateAlbumDto.md differ diff --git a/mobile/openapi/doc/UpdateAlbumDto.md b/mobile/openapi/doc/UpdateAlbumDto.md index 27ce6b365b..283b8bc29a 100644 Binary files a/mobile/openapi/doc/UpdateAlbumDto.md and b/mobile/openapi/doc/UpdateAlbumDto.md differ diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index e8a07c26dc..3f9492c73d 100644 Binary files a/mobile/openapi/lib/model/album_response_dto.dart and b/mobile/openapi/lib/model/album_response_dto.dart differ diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index d25a59c135..738df05a59 100644 Binary files a/mobile/openapi/lib/model/create_album_dto.dart and b/mobile/openapi/lib/model/create_album_dto.dart differ diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index 8fdd75b2d5..6c0bf3eca6 100644 Binary files a/mobile/openapi/lib/model/update_album_dto.dart and b/mobile/openapi/lib/model/update_album_dto.dart differ diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index da80f49920..2c01a043a3 100644 Binary files a/mobile/openapi/test/album_response_dto_test.dart and b/mobile/openapi/test/album_response_dto_test.dart differ diff --git a/mobile/openapi/test/create_album_dto_test.dart b/mobile/openapi/test/create_album_dto_test.dart index c727c20062..d23e66cf7e 100644 Binary files a/mobile/openapi/test/create_album_dto_test.dart and b/mobile/openapi/test/create_album_dto_test.dart differ diff --git a/mobile/openapi/test/update_album_dto_test.dart b/mobile/openapi/test/update_album_dto_test.dart index 8ac32dc594..7b8472ad3e 100644 Binary files a/mobile/openapi/test/update_album_dto_test.dart and b/mobile/openapi/test/update_album_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index b94c554dde..43dc2ea406 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4754,6 +4754,9 @@ "format": "date-time", "type": "string" }, + "description": { + "type": "string" + }, "id": { "type": "string" }, @@ -4786,6 +4789,7 @@ "id", "ownerId", "albumName", + "description", "createdAt", "updatedAt", "albumThumbnailAssetId", @@ -5264,6 +5268,9 @@ }, "type": "array" }, + "description": { + "type": "string" + }, "sharedWithUserIds": { "items": { "format": "uuid", @@ -6903,6 +6910,9 @@ "albumThumbnailAssetId": { "format": "uuid", "type": "string" + }, + "description": { + "type": "string" } }, "type": "object" diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index e50c8aa161..5fde07c57a 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -7,6 +7,7 @@ export class AlbumResponseDto { id!: string; ownerId!: string; albumName!: string; + description!: string; createdAt!: Date; updatedAt!: Date; albumThumbnailAssetId!: string | null; @@ -19,7 +20,7 @@ export class AlbumResponseDto { lastModifiedAssetTimestamp?: Date; } -export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { +const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { const sharedUsers: UserResponseDto[] = []; entity.sharedUsers?.forEach((user) => { @@ -29,6 +30,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { return { albumName: entity.albumName, + description: entity.description, albumThumbnailAssetId: entity.albumThumbnailAssetId, createdAt: entity.createdAt, updatedAt: entity.updatedAt, @@ -37,33 +39,13 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { owner: mapUser(entity.owner), sharedUsers, 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, }; -} +}; -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 { @ApiProperty({ type: 'integer' }) diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index b6c6204215..50eed510d6 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -156,6 +156,7 @@ describe(AlbumService.name, () => { await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({ albumName: 'Empty album', + description: '', albumThumbnailAssetId: null, assetCount: 0, assets: [], diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 246a53047a..f98cdfb1f1 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -94,6 +94,7 @@ export class AlbumService { const album = await this.albumRepository.create({ ownerId: authUser.id, albumName: dto.albumName, + description: dto.description, sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [], assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)), albumThumbnailAssetId: dto.assetIds?.[0] || null, @@ -118,6 +119,7 @@ export class AlbumService { const updatedAlbum = await this.albumRepository.update({ id: album.id, albumName: dto.albumName, + description: dto.description, albumThumbnailAssetId: dto.albumThumbnailAssetId, }); diff --git a/server/src/domain/album/dto/album-create.dto.ts b/server/src/domain/album/dto/album-create.dto.ts index 3d99683319..586cde2c64 100644 --- a/server/src/domain/album/dto/album-create.dto.ts +++ b/server/src/domain/album/dto/album-create.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { ValidateUUID } from '../../domain.util'; export class CreateAlbumDto { @@ -8,6 +8,10 @@ export class CreateAlbumDto { @ApiProperty() albumName!: string; + @IsString() + @IsOptional() + description?: string; + @ValidateUUID({ optional: true, each: true }) sharedWithUserIds?: string[]; diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index 9bbe16e3ba..8270777e2b 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,12 +1,15 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; import { ValidateUUID } from '../../domain.util'; export class UpdateAlbumDto { @IsOptional() - @ApiProperty() + @IsString() albumName?: string; + @IsOptional() + @IsString() + description?: string; + @ValidateUUID({ optional: true }) albumThumbnailAssetId?: string; } diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts index 889a025a4c..6f6ad2132e 100644 --- a/server/src/immich/controllers/album.controller.ts +++ b/server/src/immich/controllers/album.controller.ts @@ -5,8 +5,8 @@ import { AuthUserDto, BulkIdResponseDto, BulkIdsDto, - CreateAlbumDto, - UpdateAlbumDto, + CreateAlbumDto as CreateDto, + UpdateAlbumDto as UpdateDto, } from '@app/domain'; import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; @@ -34,7 +34,7 @@ export class AlbumController { } @Post() - createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) { + createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) { return this.service.create(authUser, dto); } @@ -45,7 +45,7 @@ export class AlbumController { } @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); } diff --git a/server/src/infra/entities/album.entity.ts b/server/src/infra/entities/album.entity.ts index 5cf2ebd31e..06cd7fa690 100644 --- a/server/src/infra/entities/album.entity.ts +++ b/server/src/infra/entities/album.entity.ts @@ -27,6 +27,9 @@ export class AlbumEntity { @Column({ default: 'Untitled Album' }) albumName!: string; + @Column({ type: 'text', default: '' }) + description!: string; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/server/src/infra/migrations/1691209138541-AddAlbumDescription.ts b/server/src/infra/migrations/1691209138541-AddAlbumDescription.ts new file mode 100644 index 0000000000..f4167598af --- /dev/null +++ b/server/src/infra/migrations/1691209138541-AddAlbumDescription.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAlbumDescription1691209138541 implements MigrationInterface { + name = 'AddAlbumDescription1691209138541'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" ADD "description" text NOT NULL DEFAULT ''`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "description"`); + } +} diff --git a/server/src/infra/repositories/typesense.repository.ts b/server/src/infra/repositories/typesense.repository.ts index 2ca81f19c4..bb78a0995c 100644 --- a/server/src/infra/repositories/typesense.repository.ts +++ b/server/src/infra/repositories/typesense.repository.ts @@ -234,7 +234,7 @@ export class TypesenseRepository implements ISearchRepository { .documents() .search({ q: query, - query_by: 'albumName', + query_by: ['albumName', 'description'].join(','), filter_by: this.getAlbumFilters(filters), }); diff --git a/server/src/infra/typesense-schemas/album.schema.ts b/server/src/infra/typesense-schemas/album.schema.ts index bc01aca0c7..7a7506a863 100644 --- a/server/src/infra/typesense-schemas/album.schema.ts +++ b/server/src/infra/typesense-schemas/album.schema.ts @@ -1,11 +1,12 @@ import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; -export const albumSchemaVersion = 1; +export const albumSchemaVersion = 2; export const albumSchema: CollectionCreateSchema = { name: `albums-v${albumSchemaVersion}`, fields: [ { name: 'ownerId', type: 'string', facet: false }, { name: 'albumName', type: 'string', facet: false, sort: true }, + { name: 'description', type: 'string', facet: false }, { name: 'createdAt', type: 'string', facet: false, sort: true }, { name: 'updatedAt', type: 'string', facet: false, sort: true }, ], diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index 8b10823292..ffde674f13 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -4,7 +4,7 @@ import { SharedLinkType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; -import { errorStub } from '../fixtures'; +import { errorStub, uuidStub } from '../fixtures'; import { api, db } from '../test-utils'; const user1SharedUser = 'user1SharedUser'; @@ -193,6 +193,7 @@ describe(`${AlbumController.name} (e2e)`, () => { updatedAt: expect.any(String), ownerId: user1.userId, albumName: 'New album', + description: '', albumThumbnailAssetId: null, shared: false, 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', + }); + }); + }); }); diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index e86d949768..8da88aa2d6 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -7,6 +7,7 @@ export const albumStub = { empty: Object.freeze({ id: 'album-1', albumName: 'Empty album', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -20,6 +21,7 @@ export const albumStub = { sharedWithUser: Object.freeze({ id: 'album-2', albumName: 'Empty album shared with user', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -33,6 +35,7 @@ export const albumStub = { sharedWithMultiple: Object.freeze({ id: 'album-3', albumName: 'Empty album shared with users', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -46,6 +49,7 @@ export const albumStub = { sharedWithAdmin: Object.freeze({ id: 'album-3', albumName: 'Empty album shared with admin', + description: '', ownerId: authStub.user1.id, owner: userStub.user1, assets: [], @@ -59,6 +63,7 @@ export const albumStub = { oneAsset: Object.freeze({ id: 'album-4', albumName: 'Album with one asset', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image], @@ -72,6 +77,7 @@ export const albumStub = { twoAssets: Object.freeze({ id: 'album-4a', albumName: 'Album with two assets', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image, assetStub.withLocation], @@ -85,6 +91,7 @@ export const albumStub = { emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -98,6 +105,7 @@ export const albumStub = { emptyWithValidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -111,6 +119,7 @@ export const albumStub = { oneAssetInvalidThumbnail: Object.freeze({ id: 'album-6', albumName: 'Album with one asset and invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image], @@ -124,6 +133,7 @@ export const albumStub = { oneAssetValidThumbnail: Object.freeze({ id: 'album-6', albumName: 'Album with one asset and invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image], diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 777e136cea..004df894da 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -68,6 +68,7 @@ const assetResponse: AssetResponseDto = { const albumResponse: AlbumResponseDto = { albumName: 'Test Album', + description: '', albumThumbnailAssetId: null, createdAt: today, updatedAt: today, @@ -146,6 +147,7 @@ export const sharedLinkStub = { ownerId: authStub.admin.id, owner: userStub.admin, albumName: 'Test Album', + description: '', createdAt: today, updatedAt: today, albumThumbnailAsset: null, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7d83aa12da..8af3bc4ff7 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -210,6 +210,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'createdAt': string; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'description': string; /** * * @type {string} @@ -865,6 +871,12 @@ export interface CreateAlbumDto { * @memberof CreateAlbumDto */ 'assetIds'?: Array; + /** + * + * @type {string} + * @memberof CreateAlbumDto + */ + 'description'?: string; /** * * @type {Array} @@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'albumThumbnailAssetId'?: string; + /** + * + * @type {string} + * @memberof UpdateAlbumDto + */ + 'description'?: string; } /** * diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 8661b836d8..c81f3462d0 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -44,6 +44,7 @@ import { handleError } from '../../utils/handle-error'; import { downloadArchive } from '../../utils/asset-utils'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import EditDescriptionModal from './edit-description-modal.svelte'; export let album: AlbumResponseDto; export let sharedLink: SharedLinkResponseDto | undefined = undefined; @@ -73,6 +74,7 @@ let isShowAlbumOptions = false; let isShowThumbnailSelection = false; let isShowDeleteConfirmation = false; + let isEditingDescription = false; let backUrl = '/albums'; let currentAlbumName = ''; @@ -298,6 +300,27 @@ const handleSelectAll = () => { 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; + };
@@ -405,6 +428,7 @@ {/if}
+ { if (e.key == 'Enter') { @@ -421,8 +445,10 @@ bind:value={album.albumName} disabled={!isOwned} bind:this={titleInput} + title="Edit Title" /> + {#if album.assetCount > 0}

{getDateRange()}

@@ -448,6 +474,17 @@ {/if} + + + {#if album.assetCount > 0 && !isShowAssetSelection} {:else} @@ -490,6 +527,7 @@ {#if isShowShareLinkModal} (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} /> {/if} + {#if isShowShareInfoModal} (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} /> {/if} @@ -515,3 +553,11 @@ {/if} + +{#if isEditingDescription} + (isEditingDescription = false)} + on:updated={({ detail: description }) => descriptionUpdatedHandler(description)} + /> +{/if} diff --git a/web/src/lib/components/album-page/edit-description-modal.svelte b/web/src/lib/components/album-page/edit-description-modal.svelte new file mode 100644 index 0000000000..90f339f8be --- /dev/null +++ b/web/src/lib/components/album-page/edit-description-modal.svelte @@ -0,0 +1,43 @@ + + + dispatch('close')}> +
+
+

Edit description

+
+ +
+
+ + + +
+ +
+ + +
+
+
+
diff --git a/web/src/test-data/factories/album-factory.ts b/web/src/test-data/factories/album-factory.ts index 1ea7284448..dfa5e530ea 100644 --- a/web/src/test-data/factories/album-factory.ts +++ b/web/src/test-data/factories/album-factory.ts @@ -5,6 +5,7 @@ import { userFactory } from './user-factory'; export const albumFactory = Sync.makeFactory({ albumName: Sync.each(() => faker.commerce.product()), + description: '', albumThumbnailAssetId: null, assetCount: Sync.each((i) => i % 5), assets: [],